feat(webui): implement search

This commit is contained in:
Tsiry Sandratraina
2022-11-15 11:34:39 +03:00
parent c5946020f4
commit c832ee951e
41 changed files with 822 additions and 49 deletions

1
Cargo.lock generated
View File

@@ -3114,6 +3114,7 @@ name = "music-player-storage"
version = "0.1.1"
dependencies = [
"itertools",
"md5",
"music-player-settings",
"music-player-types",
"sea-orm",

View File

@@ -26,6 +26,9 @@ pub struct Track {
pub album: Album,
pub artist: String,
pub cover: Option<String>,
pub artist_id: String,
pub album_id: String,
pub album_title: String,
}
#[Object]
@@ -69,6 +72,18 @@ impl Track {
async fn cover(&self) -> Option<String> {
self.cover.clone()
}
async fn artist_id(&self) -> &str {
&self.artist_id
}
async fn album_id(&self) -> &str {
&self.album_id
}
async fn album_title(&self) -> &str {
&self.album_title
}
}
impl From<Model> for Track {
@@ -108,6 +123,9 @@ impl From<TrackType> for Track {
artist: song.artist,
duration: Some(song.duration.as_secs_f32()),
cover: song.cover,
artist_id: song.artist_id,
album_id: song.album_id,
album_title: song.album,
..Default::default()
}
}

View File

@@ -21,3 +21,4 @@ itertools = "0.10.5"
sea-orm = { version = "0.9.2", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
tantivy = "0.18.0"
tempfile = "3.3.0"
md5 = "0.7.0"

View File

@@ -29,6 +29,8 @@ impl TrackSearcher {
schema_builder.add_text_field("genre", TEXT);
schema_builder.add_text_field("cover", STRING | STORED);
schema_builder.add_i64_field("duration", STORED);
schema_builder.add_text_field("artistId", STRING | STORED);
schema_builder.add_text_field("albumId", STRING | STORED);
let schema: Schema = schema_builder.build();
let dir = MmapDirectory::open(&index_path).unwrap();
@@ -64,6 +66,8 @@ impl TrackSearcher {
let genre = self.schema.get_field("genre").unwrap();
let cover = self.schema.get_field("cover").unwrap();
let duration = self.schema.get_field("duration").unwrap();
let artist_id = self.schema.get_field("artistId").unwrap();
let album_id = self.schema.get_field("albumId").unwrap();
let time = song.duration.as_secs_f32() as i64;
@@ -75,6 +79,8 @@ impl TrackSearcher {
genre => song.genre.clone(),
cover => song.cover.unwrap_or_default().clone(),
duration => time,
artist_id => format!("{:x}", md5::compute(song.album_artist.to_owned())),
album_id => format!("{:x}", md5::compute(song.album.to_owned()))
);
index_writer.add_document(doc)?;

View File

@@ -33,6 +33,8 @@ pub struct SimplifiedSong {
pub genre: String,
pub duration: Duration,
pub cover: Option<String>,
pub artist_id: String,
pub album_id: String,
}
#[derive(Debug, Clone, Default)]
@@ -82,9 +84,15 @@ impl From<Document> for Album {
.to_string();
let year = Some(doc.get_first(year_field).unwrap().as_i64().unwrap() as u32);
let cover = match doc.get_first(cover_field) {
Some(cover) => Some(cover.as_text().unwrap().to_string()),
Some(cover) => cover.as_text(),
None => None,
};
let cover = match cover {
Some("") => None,
Some(cover) => Some(cover.to_string()),
None => None,
};
Self {
id,
title,
@@ -133,8 +141,10 @@ impl From<Document> for SimplifiedSong {
let artist_field = schema_builder.add_text_field("artist", TEXT | STORED);
let album_field = schema_builder.add_text_field("album", TEXT | STORED);
let genre_field = schema_builder.add_text_field("genre", TEXT);
let duration_field = schema_builder.add_i64_field("duration", STORED);
let cover_field = schema_builder.add_text_field("cover", STRING | STORED);
let duration_field = schema_builder.add_i64_field("duration", STORED);
let artist_id_field = schema_builder.add_text_field("artist_id", STRING | STORED);
let album_id_field = schema_builder.add_text_field("album_id", STRING | STORED);
let id = doc
.get_first(id_field)
@@ -164,9 +174,26 @@ impl From<Document> for SimplifiedSong {
None => Duration::from_secs(0),
};
let cover = match doc.get_first(cover_field) {
Some(cover) => Some(cover.as_text().unwrap_or_default().to_string()),
Some(cover) => cover.as_text(),
None => None,
};
let cover = match cover {
Some("") => None,
Some(cover) => Some(cover.to_string()),
None => None,
};
let artist_id = doc
.get_first(artist_id_field)
.unwrap()
.as_text()
.unwrap()
.to_string();
let album_id = doc
.get_first(album_id_field)
.unwrap()
.as_text()
.unwrap()
.to_string();
Self {
id,
title,
@@ -175,6 +202,8 @@ impl From<Document> for SimplifiedSong {
genre,
duration,
cover,
artist_id,
album_id,
..Default::default()
}
}

View File

@@ -1,7 +1,7 @@
{
"files": {
"main.css": "/static/css/main.6627e378.css",
"main.js": "/static/js/main.b5171ddd.js",
"main.css": "/static/css/main.56b69c4b.css",
"main.js": "/static/js/main.1a39bb8b.js",
"static/js/787.26bf0a29.chunk.js": "/static/js/787.26bf0a29.chunk.js",
"static/media/RockfordSans-ExtraBold.otf": "/static/media/RockfordSans-ExtraBold.1513e8fd97078bfb7708.otf",
"static/media/RockfordSans-Bold.otf": "/static/media/RockfordSans-Bold.c9f599ae01b13e565598.otf",
@@ -9,12 +9,12 @@
"static/media/RockfordSans-Regular.otf": "/static/media/RockfordSans-Regular.652654f28f1c111914b9.otf",
"static/media/RockfordSans-Light.otf": "/static/media/RockfordSans-Light.b4a12e8abb38f7d105c4.otf",
"index.html": "/index.html",
"main.6627e378.css.map": "/static/css/main.6627e378.css.map",
"main.b5171ddd.js.map": "/static/js/main.b5171ddd.js.map",
"main.56b69c4b.css.map": "/static/css/main.56b69c4b.css.map",
"main.1a39bb8b.js.map": "/static/js/main.1a39bb8b.js.map",
"787.26bf0a29.chunk.js.map": "/static/js/787.26bf0a29.chunk.js.map"
},
"entrypoints": [
"static/css/main.6627e378.css",
"static/js/main.b5171ddd.js"
"static/css/main.56b69c4b.css",
"static/js/main.1a39bb8b.js"
]
}

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>Music Player</title><script defer="defer" src="/static/js/main.b5171ddd.js"></script><link href="/static/css/main.6627e378.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>Music Player</title><script defer="defer" src="/static/js/main.1a39bb8b.js"></script><link href="/static/css/main.56b69c4b.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View File

@@ -1,2 +1,2 @@
@font-face{font-family:RockfordSansLight;src:local("RockfordSansLight"),url(/static/media/RockfordSans-Light.b4a12e8abb38f7d105c4.otf) format("opentype")}@font-face{font-family:RockfordSansRegular;src:local("RockfordSans"),url(/static/media/RockfordSans-Regular.652654f28f1c111914b9.otf) format("opentype")}@font-face{font-family:RockfordSansMedium;src:local("RockfordSans"),url(/static/media/RockfordSans-Medium.e10344a796535b513215.otf) format("opentype")}@font-face{font-family:RockfordSansBold;font-weight:900;src:local("RockfordSans"),url(/static/media/RockfordSans-Bold.c9f599ae01b13e565598.otf) format("opentype")}@font-face{font-family:RockfordSansExtraBold;font-weight:900;src:local("RockfordSans"),url(/static/media/RockfordSans-ExtraBold.1513e8fd97078bfb7708.otf) format("opentype")}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:RockfordSansRegular,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0;overflow-y:hidden}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}[data-baseweb=modal]{z-index:1}:focus{outline:none!important}.play{display:none}.floating-play{display:none;left:40px;position:absolute}tr:hover td div .floating-play,tr:hover td div .play{cursor:pointer;display:block}tr:hover td div .tracknumber{display:none}tr:hover td div .album-cover{opacity:.4}tr:hover td div button{color:#000}tr td div div{overflow:hidden;text-overflow:ellipsis}a{color:#000;color:initial;text-decoration:none;text-decoration:initial}a:hover{text-decoration:underline}.album-cover-container{position:relative}.album-cover-container .floating-play{display:none;left:19px;position:absolute;top:12px}.album-cover-container:hover .floating-play{display:block}.album-cover-container:hover img{opacity:.4}
/*# sourceMappingURL=main.6627e378.css.map*/
@font-face{font-family:RockfordSansLight;src:local("RockfordSansLight"),url(/static/media/RockfordSans-Light.b4a12e8abb38f7d105c4.otf) format("opentype")}@font-face{font-family:RockfordSansRegular;src:local("RockfordSans"),url(/static/media/RockfordSans-Regular.652654f28f1c111914b9.otf) format("opentype")}@font-face{font-family:RockfordSansMedium;src:local("RockfordSans"),url(/static/media/RockfordSans-Medium.e10344a796535b513215.otf) format("opentype")}@font-face{font-family:RockfordSansBold;font-weight:900;src:local("RockfordSans"),url(/static/media/RockfordSans-Bold.c9f599ae01b13e565598.otf) format("opentype")}@font-face{font-family:RockfordSansExtraBold;font-weight:900;src:local("RockfordSans"),url(/static/media/RockfordSans-ExtraBold.1513e8fd97078bfb7708.otf) format("opentype")}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:RockfordSansRegular,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0;overflow-y:hidden}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}[data-baseweb=modal]{z-index:1}:focus{outline:none!important}.play{display:none}.floating-play{display:none;left:40px;position:absolute}tr:hover td div .floating-play,tr:hover td div .play{cursor:pointer;display:block}tr:hover td div .tracknumber{display:none}tr:hover td div .album-cover{opacity:.4}tr:hover td div button{color:#000}tr td div div{overflow:hidden;text-overflow:ellipsis}a{color:#000;color:initial;text-decoration:none;text-decoration:initial}a:hover{text-decoration:underline}.album-cover-container{position:relative}.album-cover-container .floating-play{display:none;left:19px;position:absolute;top:12px}.album-cover-container:hover .floating-play{display:block}.album-cover-container:hover img{opacity:.4}[data-baseweb=tab-panel]{padding-left:0!important;padding-right:0!important}
/*# sourceMappingURL=main.56b69c4b.css.map*/

View File

@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.56b69c4b.css","mappings":"AACA,WACE,6BAAgC,CAChC,gHACF,CAEA,WACE,+BAAkC,CAClC,6GACF,CAEA,WACE,8BAAiC,CACjC,4GACF,CAEA,WACE,4BAA+B,CAC7B,eAAgB,CAChB,0GACJ,CAEA,WACE,iCAAoC,CAClC,eAAgB,CAChB,+GACJ,CAEA,KAKE,kCAAmC,CACnC,iCAAkC,CAJlC,uJAEY,CAHZ,QAAS,CAMT,iBACF,CAEA,KACE,uEAEF,CAEA,qBACE,SACF,CAEA,OAAS,sBAAuB,CAEhC,MACE,YACF,CAEA,eACE,YAAa,CAEb,SAAU,CADV,iBAEF,CAOA,qDACE,cAAe,CACf,aACF,CAGA,6BACC,YACD,CAEA,6BACE,UACD,CAED,uBACE,UACD,CAEA,cACC,eAAgB,CAChB,sBACD,CAEA,EACG,UAAc,CAAd,aAAc,CACd,oBAAwB,CAAxB,uBACH,CAEA,QACC,yBACD,CAEA,uBACC,iBACD,CAEA,sCACC,YAAa,CAEb,SAAU,CADV,iBAAkB,CAElB,QACD,CAEA,4CACC,aACD,CAEA,iCACC,UACD,CAEA,yBACC,wBAA0B,CAC1B,yBACD","sources":["index.css"],"sourcesContent":["\n@font-face {\n font-family: 'RockfordSansLight';\n src: local('RockfordSansLight'), url(./Assets/fonts/RockfordSans-Light.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansRegular';\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Regular.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansMedium';\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Medium.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansBold';\n font-weight: 900;\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Bold.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansExtraBold';\n font-weight: 900;\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-ExtraBold.otf) format('opentype');\n}\n\nbody {\n margin: 0;\n font-family: RockfordSansRegular, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n overflow-y: hidden;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n monospace;\n}\n\n[data-baseweb=\"modal\"] {\n z-index: 1;\n}\n\n*:focus {outline:none !important}\n\n.play {\n display: none;\n}\n\n.floating-play {\n display: none;\n position: absolute;\n left: 40px;\n}\n\ntr:hover td div .play {\n cursor: pointer;\n display: block;\n}\n\ntr:hover td div .floating-play {\n cursor: pointer;\n display: block;\n}\n\n\ntr:hover td div .tracknumber {\n display: none;\n}\n\ntr:hover td div .album-cover {\n opacity: 0.4;\n }\n\ntr:hover td div button {\n color: #000;\n }\n\n tr td div div {\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n a {\n color: initial;\n text-decoration: initial;\n }\n\n a:hover {\n text-decoration: underline;\n }\n\n .album-cover-container {\n position: relative;\n }\n\n .album-cover-container .floating-play {\n display: none;\n position: absolute;\n left: 19px;\n top: 12px;\n }\n\n .album-cover-container:hover .floating-play {\n display: block;\n }\n\n .album-cover-container:hover img {\n opacity: 0.4;\n }\n\n [data-baseweb=\"tab-panel\"] {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }"],"names":[],"sourceRoot":""}

View File

@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.6627e378.css","mappings":"AACA,WACE,6BAAgC,CAChC,gHACF,CAEA,WACE,+BAAkC,CAClC,6GACF,CAEA,WACE,8BAAiC,CACjC,4GACF,CAEA,WACE,4BAA+B,CAC7B,eAAgB,CAChB,0GACJ,CAEA,WACE,iCAAoC,CAClC,eAAgB,CAChB,+GACJ,CAEA,KAKE,kCAAmC,CACnC,iCAAkC,CAJlC,uJAEY,CAHZ,QAAS,CAMT,iBACF,CAEA,KACE,uEAEF,CAEA,qBACE,SACF,CAEA,OAAS,sBAAuB,CAEhC,MACE,YACF,CAEA,eACE,YAAa,CAEb,SAAU,CADV,iBAEF,CAOA,qDACE,cAAe,CACf,aACF,CAGA,6BACC,YACD,CAEA,6BACE,UACD,CAED,uBACE,UACD,CAEA,cACC,eAAgB,CAChB,sBACD,CAEA,EACG,UAAc,CAAd,aAAc,CACd,oBAAwB,CAAxB,uBACH,CAEA,QACC,yBACD,CAEA,uBACC,iBACD,CAEA,sCACC,YAAa,CAEb,SAAU,CADV,iBAAkB,CAElB,QACD,CAEA,4CACC,aACD,CAEA,iCACC,UACD","sources":["index.css"],"sourcesContent":["\n@font-face {\n font-family: 'RockfordSansLight';\n src: local('RockfordSansLight'), url(./Assets/fonts/RockfordSans-Light.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansRegular';\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Regular.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansMedium';\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Medium.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansBold';\n font-weight: 900;\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-Bold.otf) format('opentype');\n}\n\n@font-face {\n font-family: 'RockfordSansExtraBold';\n font-weight: 900;\n src: local('RockfordSans'), url(./Assets/fonts/RockfordSans-ExtraBold.otf) format('opentype');\n}\n\nbody {\n margin: 0;\n font-family: RockfordSansRegular, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n overflow-y: hidden;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n monospace;\n}\n\n[data-baseweb=\"modal\"] {\n z-index: 1;\n}\n\n*:focus {outline:none !important}\n\n.play {\n display: none;\n}\n\n.floating-play {\n display: none;\n position: absolute;\n left: 40px;\n}\n\ntr:hover td div .play {\n cursor: pointer;\n display: block;\n}\n\ntr:hover td div .floating-play {\n cursor: pointer;\n display: block;\n}\n\n\ntr:hover td div .tracknumber {\n display: none;\n}\n\ntr:hover td div .album-cover {\n opacity: 0.4;\n }\n\ntr:hover td div button {\n color: #000;\n }\n\n tr td div div {\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n a {\n color: initial;\n text-decoration: initial;\n }\n\n a:hover {\n text-decoration: underline;\n }\n\n .album-cover-container {\n position: relative;\n }\n\n .album-cover-container .floating-play {\n display: none;\n position: absolute;\n left: 19px;\n top: 12px;\n }\n\n .album-cover-container:hover .floating-play {\n display: block;\n }\n\n .album-cover-container:hover img {\n opacity: 0.4;\n }"],"names":[],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1626,13 +1626,30 @@
{
"name": "search",
"description": null,
"args": [],
"args": [
{
"name": "keyword",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"kind": "OBJECT",
"name": "SearchResult",
"ofType": null
}
},
@@ -1718,6 +1735,89 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SearchResult",
"description": null,
"fields": [
{
"name": "albums",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Album",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "artists",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Artist",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "tracks",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Track",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "String",
@@ -1749,6 +1849,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "albumId",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "albumTitle",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "artist",
"description": null,
@@ -1765,6 +1897,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "artistId",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "artists",
"description": null,
@@ -1789,6 +1937,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cover",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "discNumber",
"description": null,

View File

@@ -4,6 +4,7 @@ import ArtistsPage from "./Containers/Artists";
import TracksPage from "./Containers/Tracks";
import AlbumDetailsPage from "./Containers/AlbumDetails";
import ArtistDetailsPage from "./Containers/ArtistDetails";
import SearchPage from "./Containers/Search";
function App() {
return (
@@ -15,6 +16,7 @@ function App() {
<Route path="/albums" element={<AlbumsPage />} />
<Route path="/albums/:id" element={<AlbumDetailsPage />} />
<Route path="/artists/:id" element={<ArtistDetailsPage />} />
<Route path="/search" element={<SearchPage />} />
</Routes>
</BrowserRouter>
);

View File

@@ -139,24 +139,18 @@ export type AlbumDetailsProps = {
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
const AlbumDetails: FC<AlbumDetailsProps> = (props) => {
const {
onBack,
onClickLibraryItem,
album,
nowPlaying,
onPlayAlbum,
onPlayNext,
} = props;
const { onBack, album, nowPlaying, onPlayAlbum, onPlayNext } = props;
const coverUrl = _.startsWith(album.cover, "https://")
? album.cover
: `/covers/${album.cover}`;
const { cover } = useCover(coverUrl);
return (
<Container>
<Sidebar active="albums" onClickLibraryItem={onClickLibraryItem} />
<Sidebar active="albums" {...props} />
<Content>
<ControlBar {...props} />
<MainContent displayHeader={false}>

View File

@@ -81,6 +81,7 @@ export type AlbumsProps = {
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
export type AlbumProps = {
@@ -105,10 +106,10 @@ const Album: FC<AlbumProps> = ({ onClick, album }) => {
};
const Albums: FC<AlbumsProps> = (props) => {
const { albums, onClickAlbum, onClickLibraryItem } = props;
const { albums, onClickAlbum } = props;
return (
<Container>
<Sidebar active="albums" onClickLibraryItem={onClickLibraryItem} />
<Sidebar active="albums" {...props} />
<Content>
<ControlBar {...props} />
<Scrollable>

View File

@@ -160,21 +160,15 @@ export type ArtistDetailsProps = {
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
const ArtistDetails: FC<ArtistDetailsProps> = (props) => {
const {
onBack,
onClickLibraryItem,
artist,
tracks,
albums,
onPlayArtistTracks,
onPlayNext,
} = props;
const { onBack, artist, tracks, albums, onPlayArtistTracks, onPlayNext } =
props;
return (
<Container>
<Sidebar active="artists" onClickLibraryItem={onClickLibraryItem} />
<Sidebar active="artists" {...props} />
<Content>
<ControlBar {...props} />
<MainContent displayHeader={false}>

View File

@@ -73,13 +73,14 @@ export type ArtistsProps = {
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
const Artists: FC<ArtistsProps> = (props) => {
const { onClickLibraryItem, onClickArtist, artists } = props;
const { onClickArtist, artists } = props;
return (
<Container>
<Sidebar active="artists" onClickLibraryItem={onClickLibraryItem} />
<Sidebar active="artists" {...props} />
<Content>
<ControlBar {...props} />
<Scrollable>

View File

@@ -1,10 +1,23 @@
import { Input } from "baseui/input";
import { FC } from "react";
const Search = () => {
export type SearchProps = {
onSearch: (query: string) => void;
};
const Search: FC<SearchProps> = ({ onSearch }) => {
const handleKeyPress = (
event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (event.key === "Enter") {
onSearch(event.currentTarget.value);
}
};
return (
<>
<Input
placeholder="Search"
onKeyPress={handleKeyPress}
overrides={{
Root: {
style: {

View File

@@ -0,0 +1,88 @@
import styled from "@emotion/styled";
import { Cell, Grid } from "baseui/layout-grid";
import { FC } from "react";
import { useCover } from "../../../Hooks/useCover";
import AlbumIcon from "../../Icons/AlbumCover";
const AlbumCover = styled.img`
height: 220px;
width: 220px;
border-radius: 3px;
cursor: pointer;
`;
const NoAlbumCover = styled.div`
height: 220px;
width: 220px;
border-radius: 3px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
background-color: #ddaefb14;
`;
const Artist = styled.div`
color: #828282;
margin-bottom: 56px;
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
`;
const Title = styled.div`
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
`;
const Wrapper = styled.div`
margin-top: 34px;
`;
export type AlbumProps = {
onClick: (item: any) => void;
album: any;
};
const Album: FC<AlbumProps> = ({ onClick, album }) => {
const { cover } = useCover(album.cover);
return (
<Wrapper>
{cover && <AlbumCover src={cover} onClick={() => onClick(album)} />}
{!cover && (
<NoAlbumCover onClick={() => onClick(album)}>
<AlbumIcon />
</NoAlbumCover>
)}
<Title onClick={() => onClick(album)}>{album.title}</Title>
<Artist>{album.artist}</Artist>
</Wrapper>
);
};
export type AlbumsProps = {
albums: any[];
onClickAlbum: (album: any) => void;
};
const Albums: FC<AlbumsProps> = (props) => {
const { albums, onClickAlbum } = props;
return (
<>
<Grid gridColumns={[2, 3, 4, 6]} gridMargins={[8, 16, 18]}>
{albums.map((item) => (
<Cell key={item.id}>
<Album onClick={onClickAlbum} album={item} />
</Cell>
))}
</Grid>
</>
);
};
export default Albums;

View File

@@ -0,0 +1,71 @@
import styled from "@emotion/styled";
import { Cell, Grid } from "baseui/layout-grid";
import { FC } from "react";
import Artist from "../../Icons/Artist";
const ArtistCover = styled.img`
height: 220px;
width: 220px;
border-radius: 110px;
cursor: pointer;
`;
const Wrapper = styled.div`
margin-top: 34px;
`;
const NoArtistCover = styled.div`
height: 220px;
width: 220px;
border-radius: 110px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
background-color: #f3f3f3b9;
`;
const ArtistName = styled.div`
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
margin-top: 20px;
margin-bottom: 18px;
text-align: center;
width: 220px;
`;
export type ArtistsProps = {
artists: any[];
onClickArtist: (artist: any) => void;
};
const Artists: FC<ArtistsProps> = (props) => {
const { artists, onClickArtist } = props;
return (
<Wrapper>
<Grid gridColumns={[2, 3, 4]} gridMargins={[8, 16, 18]}>
{artists.map((item) => (
<Cell key={item.id}>
{item.cover && (
<ArtistCover
src={item.cover}
onClick={() => onClickArtist(item)}
/>
)}
{!item.cover && (
<NoArtistCover onClick={() => onClickArtist(item)}>
<Artist width={75} height={75} color="#a4a3a3" />
</NoArtistCover>
)}
<ArtistName>{item.name}</ArtistName>
</Cell>
))}
</Grid>
</Wrapper>
);
};
export default Artists;

View File

@@ -0,0 +1,9 @@
import { FC } from "react";
export type PlaylistsProps = {};
const Playlists: FC<PlaylistsProps> = (props) => {
return <></>;
};
export default Playlists;

View File

@@ -0,0 +1,103 @@
import styled from "@emotion/styled";
import { FC, useState } from "react";
import { Track } from "../../Types";
import ControlBar from "../ControlBar";
import Sidebar from "../Sidebar";
import { Tabs, Tab } from "baseui/tabs-motion";
import Tracks from "./Tracks";
import Albums from "./Albums";
import Artists from "./Artists";
import Playlists from "./Playlists";
const Container = styled.div`
display: flex;
flex-direction: row;
`;
const Content = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
const Results = styled.div`
overflow-y: auto;
height: calc(100vh - 146px);
`;
export type SearchResultsProps = {
tracks: any[];
albums: any[];
artists: any[];
onClickAlbum: (album: any) => void;
onClickArtist: (artist: any) => void;
onClickLibraryItem: (item: string) => void;
onPlay: () => void;
onPause: () => void;
onNext: () => void;
onPrevious: () => void;
onShuffle: () => void;
onRepeat: () => void;
nowPlaying: any;
onPlayTrack: (id: string, postion?: number) => void;
nextTracks: Track[];
previousTracks: Track[];
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
const SearchResults: FC<SearchResultsProps> = (props) => {
const [activeKey, setActiveKey] = useState<React.Key>(0);
const { tracks, nowPlaying, onPlayTrack, onPlayNext } = props;
return (
<Container>
<Sidebar active={""} {...props} />
<Content>
<ControlBar {...props} />
<div>
<Tabs
activeKey={activeKey}
onChange={({ activeKey }) => setActiveKey(activeKey)}
overrides={{
TabList: {
style: {
marginLeft: "26px",
marginRight: "26px",
},
},
TabBorder: {
style: {
marginLeft: "26px",
marginRight: "26px",
},
},
}}
>
<Tab title="Tracks">
<Results>
<Tracks {...props} />
</Results>
</Tab>
<Tab title="Albums">
<Results>
<Albums {...props} />
</Results>
</Tab>
<Tab title="Artists">
<Results>
<Artists {...props} />
</Results>
</Tab>
<Tab title="Playlists">
<Playlists />
</Tab>
</Tabs>
</div>
</Content>
</Container>
);
};
export default SearchResults;

View File

@@ -0,0 +1,30 @@
import styled from "@emotion/styled";
import { FC } from "react";
import TracksTable from "../../TracksTable";
export type TracksProps = {
tracks: any[];
nowPlaying: any;
onPlayTrack: (id: string, postion?: number) => void;
onPlayNext: (id: string) => void;
};
const Tracks: FC<TracksProps> = (props) => {
const { tracks, nowPlaying, onPlayTrack, onPlayNext } = props;
return (
<TracksTable
tracks={tracks}
currentTrackId={nowPlaying.id}
isPlaying={nowPlaying.isPlaying}
onPlayTrack={onPlayTrack}
onPlayNext={onPlayNext}
maxHeight="initial"
/>
);
};
Tracks.defaultProps = {
tracks: [],
};
export default Tracks;

View File

@@ -0,0 +1,3 @@
import SearchResults from "./SearchResults";
export default SearchResults;

View File

@@ -16,12 +16,13 @@ const Container = styled.div`
export type SidebarProps = {
active?: string;
onClickLibraryItem: (item: string) => void;
onSearch: (query: string) => void;
};
const Sidebar: FC<SidebarProps> = (props) => {
return (
<Container>
<Search />
<Search {...props} />
<Library {...props} />
<Playlists />
</Container>

View File

@@ -33,14 +33,14 @@ export type TracksProps = {
onPlayNext: (id: string) => void;
onPlayTrackAt: (position: number) => void;
onRemoveTrackAt: (position: number) => void;
onSearch: (query: string) => void;
};
const Tracks: FC<TracksProps> = (props) => {
const { onClickLibraryItem, tracks, nowPlaying, onPlayTrack, onPlayNext } =
props;
const { tracks, nowPlaying, onPlayTrack, onPlayNext } = props;
return (
<Container>
<Sidebar active="tracks" onClickLibraryItem={onClickLibraryItem} />
<Sidebar active="tracks" {...props} />
<Content>
<ControlBar {...props} />
<MainContent title="Tracks">

View File

@@ -4,6 +4,7 @@ import AlbumDetails from "../../Components/AlbumDetails";
import { useGetAlbumQuery } from "../../Hooks/GraphQL";
import { useTimeFormat } from "../../Hooks/useFormat";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const AlbumDetailsPage = () => {
const params = useParams();
@@ -33,6 +34,7 @@ const AlbumDetailsPage = () => {
playTrackAt,
removeTrackAt,
} = usePlayback();
const { onSearch } = useSearch();
const album =
!loading && data
? {
@@ -70,6 +72,7 @@ const AlbumDetailsPage = () => {
onPlayNext={(trackId) => playNext({ variables: { trackId } })}
onPlayTrackAt={(position) => playTrackAt({ variables: { position } })}
onRemoveTrackAt={(position) => removeTrackAt({ variables: { position } })}
onSearch={(query) => navigate(`/search?q=${query}`)}
/>
);
};

View File

@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import Albums from "../../Components/Albums";
import { useGetAlbumsQuery } from "../../Hooks/GraphQL";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const AlbumsPage = () => {
const { data, loading } = useGetAlbumsQuery({
@@ -20,6 +21,7 @@ const AlbumsPage = () => {
removeTrackAt,
} = usePlayback();
const navigate = useNavigate();
const { onSearch } = useSearch();
const albums = !loading && data ? data.albums : [];
return (
<Albums
@@ -43,6 +45,7 @@ const AlbumsPage = () => {
onPlayNext={(trackId) => playNext({ variables: { trackId } })}
onPlayTrackAt={(position) => playTrackAt({ variables: { position } })}
onRemoveTrackAt={(position) => removeTrackAt({ variables: { position } })}
onSearch={(query) => navigate(`/search?q=${query}`)}
/>
);
};

View File

@@ -4,6 +4,7 @@ import ArtistDetails from "../../Components/ArtistDetails";
import { useGetArtistQuery } from "../../Hooks/GraphQL";
import { useTimeFormat } from "../../Hooks/useFormat";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const ArtistDetailsPage = () => {
const params = useParams();
@@ -33,6 +34,7 @@ const ArtistDetailsPage = () => {
playTrackAt,
removeTrackAt,
} = usePlayback();
const { onSearch } = useSearch();
const artist = !loading && data ? data.artist : {};
const tracks =
!loading && data
@@ -79,6 +81,7 @@ const ArtistDetailsPage = () => {
onPlayNext={(trackId) => playNext({ variables: { trackId } })}
onPlayTrackAt={(position) => playTrackAt({ variables: { position } })}
onRemoveTrackAt={(position) => removeTrackAt({ variables: { position } })}
onSearch={(query) => navigate(`/search?q=${query}`)}
/>
);
};

View File

@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import Artists from "../../Components/Artists";
import { useGetArtistsQuery } from "../../Hooks/GraphQL";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const ArtistsPage = () => {
const { data, loading } = useGetArtistsQuery();
@@ -18,6 +19,7 @@ const ArtistsPage = () => {
playTrackAt,
removeTrackAt,
} = usePlayback();
const { onSearch } = useSearch();
const artists = !loading && data ? data.artists : [];
return (
<Artists
@@ -40,6 +42,7 @@ const ArtistsPage = () => {
onPlayNext={(trackId) => playNext({ variables: { trackId } })}
onPlayTrackAt={(position) => playTrackAt({ variables: { position } })}
onRemoveTrackAt={(position) => removeTrackAt({ variables: { position } })}
onSearch={(query) => navigate(`/search?q=${query}`)}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import SearchResults from "../../Components/SearchResults";
import { useTimeFormat } from "../../Hooks/useFormat";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const SearchPage = () => {
const navigate = useNavigate();
const { formatTime } = useTimeFormat();
const {
play,
pause,
next,
previous,
nowPlaying,
nextTracks,
previousTracks,
playNext,
playTrackAt,
removeTrackAt,
} = usePlayback();
const [params] = useSearchParams();
const { onSearch, results, query } = useSearch();
const q = useMemo(() => params.get("q"), [params]);
useEffect(() => {
if (q && q !== null) {
onSearch(q);
}
}, [q]);
return (
<>
<SearchResults
tracks={results.tracks.map((x) => ({
...x,
time: formatTime(x.duration * 1000),
}))}
albums={results.albums}
artists={results.artists}
onClickAlbum={({ id }) => navigate(`/album/${id}`)}
onClickArtist={({ id }) => navigate(`/artists/${id}`)}
onClickLibraryItem={(item) => navigate(`/${item}`)}
onPlay={() => play()}
onPause={() => pause()}
onNext={() => next()}
onPrevious={() => previous()}
onShuffle={() => {}}
onRepeat={() => {}}
nowPlaying={nowPlaying}
onPlayTrack={(id, position) => {}}
nextTracks={nextTracks}
previousTracks={previousTracks}
onPlayNext={(trackId) => playNext({ variables: { trackId } })}
onPlayTrackAt={(position) => playTrackAt({ variables: { position } })}
onRemoveTrackAt={(position) =>
removeTrackAt({ variables: { position } })
}
onSearch={onSearch}
/>
</>
);
};
export default SearchPage;

View File

@@ -3,6 +3,7 @@ import Tracks from "../../Components/Tracks";
import { useGetTracksQuery } from "../../Hooks/GraphQL";
import { useTimeFormat } from "../../Hooks/useFormat";
import { usePlayback } from "../../Hooks/usePlayback";
import { useSearch } from "../../Hooks/useSearch";
const TracksPage = () => {
const { data, loading } = useGetTracksQuery({
@@ -22,6 +23,7 @@ const TracksPage = () => {
playTrackAt,
removeTrackAt,
} = usePlayback();
const { onSearch } = useSearch();
const tracks = !loading && data ? data.tracks : [];
return (
<>
@@ -52,6 +54,7 @@ const TracksPage = () => {
onRemoveTrackAt={(position) =>
removeTrackAt({ variables: { position } })
}
onSearch={(query) => navigate(`/search?q=${query}`)}
/>
</>
);

View File

@@ -89,3 +89,31 @@ export const GET_ALBUM = gql`
}
${ALBUM_FRAGMENT}
`;
export const SEARCH = gql`
query Search($keyword: String!) {
search(keyword: $keyword) {
artists {
id
name
picture
}
albums {
id
title
artist
cover
}
tracks {
id
title
artist
duration
cover
artistId
albumId
albumTitle
}
}
}
`;

View File

@@ -188,7 +188,7 @@ export type Query = {
getVolume: Scalars['Int'];
playlist: Scalars['Boolean'];
playlists: Scalars['Boolean'];
search: Scalars['Boolean'];
search: SearchResult;
track: Track;
tracklistTracks: Tracklist;
tracks: Array<Track>;
@@ -210,15 +210,31 @@ export type QueryGetPlaylistTracksArgs = {
};
export type QuerySearchArgs = {
keyword: Scalars['String'];
};
export type QueryTrackArgs = {
id: Scalars['ID'];
};
export type SearchResult = {
__typename?: 'SearchResult';
albums: Array<Album>;
artists: Array<Artist>;
tracks: Array<Track>;
};
export type Track = {
__typename?: 'Track';
album: Album;
albumId: Scalars['String'];
albumTitle: Scalars['String'];
artist: Scalars['String'];
artistId: Scalars['String'];
artists: Array<Artist>;
cover?: Maybe<Scalars['String']>;
discNumber: Scalars['Int'];
duration?: Maybe<Scalars['Float']>;
id: Scalars['String'];
@@ -277,6 +293,13 @@ export type GetAlbumQueryVariables = Exact<{
export type GetAlbumQuery = { __typename?: 'Query', album: { __typename?: 'Album', id: string, title: string, artist: string, year?: number | null, cover?: string | null, tracks: Array<{ __typename?: 'Track', id: string, trackNumber?: number | null, title: string, artist: string, duration?: number | null, artists: Array<{ __typename?: 'Artist', id: string, name: string }> }> } };
export type SearchQueryVariables = Exact<{
keyword: Scalars['String'];
}>;
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResult', artists: Array<{ __typename?: 'Artist', id: string, name: string, picture: string }>, albums: Array<{ __typename?: 'Album', id: string, title: string, artist: string, cover?: string | null }>, tracks: Array<{ __typename?: 'Track', id: string, title: string, artist: string, duration?: number | null, cover?: string | null, artistId: string, albumId: string, albumTitle: string }> } };
export type NextMutationVariables = Exact<{ [key: string]: never; }>;
@@ -591,6 +614,61 @@ export function useGetAlbumLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<G
export type GetAlbumQueryHookResult = ReturnType<typeof useGetAlbumQuery>;
export type GetAlbumLazyQueryHookResult = ReturnType<typeof useGetAlbumLazyQuery>;
export type GetAlbumQueryResult = Apollo.QueryResult<GetAlbumQuery, GetAlbumQueryVariables>;
export const SearchDocument = gql`
query Search($keyword: String!) {
search(keyword: $keyword) {
artists {
id
name
picture
}
albums {
id
title
artist
cover
}
tracks {
id
title
artist
duration
cover
artistId
albumId
albumTitle
}
}
}
`;
/**
* __useSearchQuery__
*
* To run a query within a React component, call `useSearchQuery` and pass it any options that fit your needs.
* When your component renders, `useSearchQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useSearchQuery({
* variables: {
* keyword: // value for 'keyword'
* },
* });
*/
export function useSearchQuery(baseOptions: Apollo.QueryHookOptions<SearchQuery, SearchQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SearchQuery, SearchQueryVariables>(SearchDocument, options);
}
export function useSearchLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchQuery, SearchQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SearchQuery, SearchQueryVariables>(SearchDocument, options);
}
export type SearchQueryHookResult = ReturnType<typeof useSearchQuery>;
export type SearchLazyQueryHookResult = ReturnType<typeof useSearchLazyQuery>;
export type SearchQueryResult = Apollo.QueryResult<SearchQuery, SearchQueryVariables>;
export const NextDocument = gql`
mutation Next {
next

View File

@@ -0,0 +1,55 @@
import { useState } from "react";
import { useSearchLazyQuery } from "./GraphQL";
import { Album, Artist, Track } from "../Types";
export const useSearch = () => {
const [query, setQuery] = useState("");
const [results, setResults] = useState<{
tracks: Track[];
artists: Artist[];
albums: Album[];
playlists: any[];
}>({
albums: [],
artists: [],
tracks: [],
playlists: [],
});
const [search] = useSearchLazyQuery();
const onSearch = async (keyword: string) => {
setQuery(query);
const { data } = await search({
variables: {
keyword,
},
});
setResults({
albums:
data?.search.albums.map((x) => ({
...x,
cover: x.cover ? `/covers/${x.cover}` : undefined,
year: 0,
})) || [],
artists: data?.search.artists || [],
tracks:
data?.search.tracks.map((x) => ({
...x,
cover: x.cover ? `/covers/${x.cover}` : undefined,
duration: x.duration!,
album: x.albumTitle,
artistId: x.artistId,
albumId: x.albumId,
})) || [],
playlists: [],
});
};
return {
query,
results,
onSearch,
};
};

View File

@@ -111,4 +111,9 @@ tr:hover td div button {
.album-cover-container:hover img {
opacity: 0.4;
}
[data-baseweb="tab-panel"] {
padding-left: 0 !important;
padding-right: 0 !important;
}

View File

@@ -114,6 +114,7 @@ pub async fn start_webui(
.route("/albums", web::get().to(index_spa))
.route("/artists/{_:.*}", web::get().to(index_spa))
.route("/albums/{_:.*}", web::get().to(index_spa))
.route("/search", web::get().to(index_spa))
.service(dist)
})
.bind(addr)?