92 Commits

Author SHA1 Message Date
cwilvx
9222e94b6c fix artist header ambient 2025-01-31 12:01:43 +03:00
cwilvx
54c165b64a fix: remove item from page 2025-01-31 11:25:55 +03:00
Mungai Njoroge
591509ebaf introducing pages
Pages
2025-01-29 12:43:20 +03:00
cwilvx
80a0bdbbf1 remove root dir draft settings 2025-01-29 12:30:42 +03:00
cwilvx
2e27da3f9f fix cardscroller 2025-01-29 12:28:51 +03:00
cwilvx
74bf8f5d78 context menu on artist 2025-01-29 12:05:27 +03:00
cwilvx
bfdefc37fd album card context menu 2025-01-29 11:43:02 +03:00
cwilvx
44a877b9c9 enable delete 2025-01-28 10:44:52 +03:00
cwilvx
db93fd554e first draft 2025-01-28 09:17:37 +03:00
cwilvx
40a7ad084c revert bottom bar test bitrate 2025-01-28 06:41:47 +03:00
cwilvx
e44aa01d63 move /home to /nothome 2025-01-07 23:21:44 +03:00
cwilvx
192e705890 add explicit flag 2025-01-06 00:18:34 +03:00
cwilvx
50f92b65ab revert default settings page 2024-12-30 21:10:07 +03:00
cwilvx
a5aea45cd6 Merge branch 'lastfm' 2024-12-30 21:01:04 +03:00
cwilvx
56b1ab35d3 lastfm integration 2024-12-30 20:58:46 +03:00
Mungai Njoroge
cc93fe7419 Merge pull request #39 from swingmx/another-one
Recommendations and misc stuff
2024-12-28 16:04:52 +03:00
cwilvx
56d1c9da90 fix mix images 2024-12-26 21:40:15 +03:00
cwilvx
da611f5e8e saving mixes + see all mixes 2024-12-26 17:32:48 +03:00
cwilvx
b9e767b3c3 fix: mix images 2024-12-11 14:20:46 +03:00
cwilvx
1eaf18ae75 handle custom mixes 2024-11-27 12:36:07 +03:00
cwilvx
e420dc3aac fix: playlist card images on home 2024-11-24 18:14:01 +03:00
cwilvx
9b938194a6 fix: logtrack worker url formation 2024-11-24 18:08:54 +03:00
cwilvx
54ab071803 Merge branch 'master' into another-one 2024-11-24 18:08:33 +03:00
skilletfun
b3484b08dd merge #40 from @skilletfun
* Add Play Button to PlaylistCard

* fix: fetch all playlist tracks from playBtn play

---------

Co-authored-by: skilletfun <s.laptev@astralab.ai>
Co-authored-by: cwilvx <geoffreymungai45@gmail.com>
2024-11-24 17:58:57 +03:00
cwilvx
96178c462f fix: link page title setting to toggle 2024-11-22 07:02:28 +03:00
cwilvx
2c4dad299b Merge branch 'master' into another-one 2024-11-21 14:29:41 +03:00
cwilvx
0fcbe03bab make stats component scrollable 2024-11-21 14:28:42 +03:00
Simon
6775b7abaf merge pr #37 from @Simonh2o
~ Added heart for favorited tracks, excluding the /favorites pages (#37) ~
* Added heart for favorited tracks, excluding the /favorites pages

* increased z-index of profile dropdown, some site elements were overlaying it

* very minor changes to btns, inputs, placeholders

* made search icon less intense

* various responsive fixes etc, isSmall on 724px for 'tracks'?

* Changed nav buttons slightly

* small fixes to heart pos, arrow pos context menu, active class for thumb nowplaying

* fixed children context menu cursor always being pointer

* Changes to profile dropdown

* some icons missing active animation, fixed padding play btn, right bar track pos, sidenav toggle

* fixed gradient animation for album and artist cards

* changed dropdown again

* hiding fav icon on lower viewport

* fixed some active click area bugs, and changed left side thumbnail

* right sidebar scrollbar and tracks fix

* adjusted topnav and bottombar sizing, change folder breadcrumb bg

* fixed some track titles for responsive

* playlist page small fixes

* Changes to the notification toasts

* Changed now playing controls responsive

* more space between bottom progress bar and play btns

---------

Co-authored-by: Stannnnn <stann@live.nl>
Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
2024-11-21 13:57:31 +03:00
cwilvx
275877a258 fix: now playing image not being shown 2024-11-21 13:37:35 +03:00
cwilvx
a2772b3945 force alternate layout from v2.0.0
+ attempt to fix the late silence data issue
2024-11-21 12:33:57 +03:00
Mungai Njoroge
ea48380699 merge #38 from @Type-Delta
Fix various UI and Playback issues on mobile browser
2024-11-21 12:10:39 +03:00
Type-Delta
818c37a6be feat: add seek controll to mediaNotification 2024-11-20 17:14:38 +07:00
Type-Delta
a711007e66 fix: second track being blocked by autoplay policy 2024-11-20 17:13:40 +07:00
cwilvx
4165e13aaa implement card limit on /home 2024-11-17 22:19:23 +03:00
cwilvx
de353bf534 migrate to /home for homepage items 2024-11-17 21:39:30 +03:00
Type-Delta
c2a3fe5725 fix: playback won't start/continue to next track on safari & prevent autoplay blocking on mobile 2024-11-17 09:53:35 +07:00
Type-Delta
32d06850e4 fix: <DynamicScroller> scrolling bug on touchscreen for ArtistView & NowPlaying view 2024-11-17 09:40:13 +07:00
Type-Delta
d9b14c0bf7 fix: arrow icon in file dropdown button for sizing error in safari 2024-11-17 09:05:23 +07:00
Type-Delta
b18b411005 fix: nowplaying view won't show Progress bar on Tablet (vertical) 2024-11-17 09:03:39 +07:00
Type-Delta
79ba8b0f6d fix: file dropdown button display above avatar dropdown menu 2024-11-17 08:59:46 +07:00
Type-Delta
67ca114f7c fix: <body> sizing on safari 2024-11-17 08:56:28 +07:00
cwilvx
43c6638f40 show artist mix image on now playing 2024-11-01 12:22:57 +03:00
cwilvx
0d0d519213 use cloud mix images 2024-10-29 22:40:44 +03:00
cwilvx
ab7075726d update refs 2024-10-29 02:09:51 +03:00
cwilvx
f4117a452f use artist color in mix cover 2024-10-28 16:42:30 +03:00
cwilvx
00f6181cbd first recommendations draft 2024-10-25 23:26:21 +03:00
cwilvx
ed847077ee fix: indexes on fav tracks page 2024-10-21 10:17:49 +03:00
cwilvx
72915c8367 force legacy streaming 2024-10-21 10:01:19 +03:00
cwilvx
866c67a154 fix: view all favorites 2024-10-21 08:45:55 +03:00
cwilvx
387c60165c fix: search loadmore 2024-10-21 08:30:50 +03:00
cwilvx
35aca59508 remove stats file 2024-10-15 15:31:43 +03:00
cwilvx
57bd7c151f show stats in album and artist pages 2024-10-15 15:30:56 +03:00
cwilvx
5ff9b67b5e update duration 2024-10-14 17:24:35 +03:00
cwilvx
418f326366 add loader, image and router link 2024-10-14 17:23:11 +03:00
cwilvx
c0c84504e0 fix: spacing on stats 2024-10-14 13:46:40 +03:00
cwilvx
e1e30565de use tabs to render charts 2024-10-14 13:43:58 +03:00
cwilvx
3593e4ac8e add title to stat item 2024-10-13 20:07:15 +03:00
cwilvx
e7276a3552 rearrange stats page 2024-10-13 20:03:48 +03:00
cwilvx
850d573f91 accomodate static date ranges 2024-10-13 19:16:35 +03:00
cwilvx
c78b24f088 add stats section 2024-10-08 00:41:21 +03:00
cwilvx
10e48cb068 update duration 2024-10-05 08:37:39 +03:00
cwilvx
c6e5f9d740 initial stats draft 2024-10-05 08:33:05 +03:00
cwilvx
76bcf51eab update default value for disabled setting 2024-09-21 19:40:13 +03:00
cwilvx
5bc21f98a8 feat: add transcoding and backup & restore settings 2024-09-21 19:39:19 +03:00
cwilvx
4c03644389 keep track order when adding a folder to a playlist 2024-09-08 23:55:22 +03:00
cwilvx
21ffbc3842 align help text on track to the right 2024-09-08 23:21:05 +03:00
cwilvx
0f42c48ca1 try: persisting home page content 2024-09-08 23:04:40 +03:00
cwilvx
f966df7581 show help text on artist tracks 2024-09-08 12:52:32 +03:00
cwilvx
9f714eef75 fix: playback randomly stopping
+ add robots.txt
+ expiriment with the web audio API at @/composables/usePlayer.ts
2024-09-07 22:57:32 +03:00
Mungai Njoroge
1c054f17b9 merge #34 from @Simonh2o
breadcrumb fix & volume slider styles
2024-09-02 20:12:25 +03:00
Simonh2o
c53c937cac login modal overflow responsiveness fix 2024-09-02 19:07:43 +02:00
Simonh2o
90b72b5f1c added some transitions, effects on hover 2024-09-02 18:55:33 +02:00
Simonh2o
d740ed43be login modal inputs, bolder font login greeting 2024-09-02 18:26:54 +02:00
Simonh2o
9899f70657 fixed font on scan interval input 2024-09-02 18:14:54 +02:00
Stannnnn
606ee6cecd Merge remote-tracking branch 'upstream/master' 2024-09-02 17:46:39 +02:00
cwilvx
1aeb3dc1d1 use limit=-1 to fetch all tracks 2024-08-31 12:20:04 +03:00
Mungai Njoroge
bf471049e4 Merge pull request #35 from swingmx/the-big-one
The big one
2024-08-31 12:16:41 +03:00
Simonh2o
9533a0db19 changed placeholder style for other inputs 2024-08-29 02:29:35 +02:00
Simonh2o
e33800281b z-index fix for thumbnails overlapping modal 2024-08-29 01:58:29 +02:00
Simonh2o
1aeddd95b5 tiny changes to the main searchbar 2024-08-29 01:17:37 +02:00
Simonh2o
5cef926675 Placeholder search text more "bold" 2024-08-28 16:09:42 +02:00
Simonh2o
767b35dd08 Fixed issue where scrollbar was smaller after search 2024-08-28 16:09:01 +02:00
Simonh2o
33541ea964 changed bg colors for backforward buttons 2024-08-27 02:53:38 +02:00
Simonh2o
bc2152c45c fixed spacing, e.g properly lined up generic titles with artist headers etc 2024-08-27 02:41:17 +02:00
Simonh2o
227963556c removed the fixed modal height so the window takes up available space 2024-08-26 23:52:50 +02:00
Simonh2o
01254f64f0 Fixed/separated search item styling on right sidebar and main search bar, sidebar scrollbar size fix 2024-07-24 15:52:30 +02:00
Simonh2o
b14ff26932 fixed responsive scroll for login modal 2024-07-23 00:59:51 +02:00
Simonh2o
09bb5ae7b0 added spacing between scan and settings item, equal to what settings menu has 2024-07-04 21:36:43 +02:00
Simonh2o
e954dbf713 more spacing between settings sidebar items & root directories small icons fix 2024-07-04 02:03:11 +02:00
Simonh2o
8fcff3d958 songlist text overflow and duration alignment fix 2024-07-03 23:26:00 +02:00
Simonh2o
8b4605e3bf Added background styling to volume slider bar, reduced main scrollbar border 2024-06-23 01:52:07 +02:00
Simonh2o
ef35c517af Fix for breadcrumb artifact on windows? 2024-06-22 20:36:13 +02:00
152 changed files with 8249 additions and 4144 deletions

View File

@@ -24,7 +24,7 @@
"pinia-plugin-persistedstate": "^3.2.0",
"qr-code-styling": "^1.6.0-rc.1",
"v-wave": "^1.5.0",
"vue": "^v3.2.45",
"vue": "^v3.5.13",
"vue-boring-avatars": "^1.4.0",
"vue-debounce": "^3.0.2",
"vue-router": "^4.1.3",
@@ -45,6 +45,7 @@
"typescript": "^5.0.4",
"vite": "^3.0.4",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.16.4",
"vite-plugin-singlefile": "^0.13.5",
"vite-svg-loader": "^4.0.0",

View File

@@ -1,2 +1,2 @@
User-agent: *
Disallow:
Disallow: /

View File

@@ -2,7 +2,8 @@ onmessage = (e) => {
const { trackhash, duration, source, timestamp } = e.data;
const is_dev = location.port === "5173";
const base_url = is_dev ? "http://localhost:1980" : location.origin;
const protocol = location.protocol.replace(':', '');
const base_url = is_dev ? `${protocol}://${location.hostname}:1980` : location.origin;
const url = base_url + "/logger/track/log";
fetch(url, {

View File

@@ -147,6 +147,7 @@ onMounted(async () => {
<script lang="ts">
// Detect OS & browser agents and add class
import { defineComponent } from "vue";
import usePlayer from "./composables/usePlayer";
export default defineComponent({
name: "OsAndBrowserSpecificContent",
mounted() {

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.69434 13.6455C5.69434 13.9092 5.80859 14.1641 6.01074 14.3574L11.8027 20.1494C12.0137 20.3516 12.251 20.4482 12.4883 20.4482C13.042 20.4482 13.4375 20.0527 13.4375 19.5254C13.4375 19.2529 13.332 19.0156 13.1562 18.8486L11.1875 16.8535L8.58594 14.4805L10.6426 14.6035H21.3301C21.9014 14.6035 22.3057 14.208 22.3057 13.6455C22.3057 13.0742 21.9014 12.6875 21.3301 12.6875H10.6426L8.59473 12.8105L11.1875 10.4375L13.1562 8.44238C13.332 8.2666 13.4375 8.0293 13.4375 7.75684C13.4375 7.22949 13.042 6.84277 12.4883 6.84277C12.251 6.84277 12.0137 6.93066 11.7852 7.15039L6.01074 12.9336C5.80859 13.1182 5.69434 13.3818 5.69434 13.6455Z" fill="#F2F2F2"/>
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.69434 13.6455C5.69434 13.9092 5.80859 14.1641 6.01074 14.3574L11.8027 20.1494C12.0137 20.3516 12.251 20.4482 12.4883 20.4482C13.042 20.4482 13.4375 20.0527 13.4375 19.5254C13.4375 19.2529 13.332 19.0156 13.1562 18.8486L11.1875 16.8535L8.58594 14.4805L10.6426 14.6035H21.3301C21.9014 14.6035 22.3057 14.208 22.3057 13.6455C22.3057 13.0742 21.9014 12.6875 21.3301 12.6875H10.6426L8.59473 12.8105L11.1875 10.4375L13.1562 8.44238C13.332 8.2666 13.4375 8.0293 13.4375 7.75684C13.4375 7.22949 13.042 6.84277 12.4883 6.84277C12.251 6.84277 12.0137 6.93066 11.7852 7.15039L6.01074 12.9336C5.80859 13.1182 5.69434 13.3818 5.69434 13.6455Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50374 26.7058C8.13702 26.7058 8.53803 26.3755 9.5421 25.3961L14.0693 20.9202C14.1258 20.8637 14.2249 20.8637 14.2718 20.9202L18.8011 25.3982C19.8073 26.3776 20.2019 26.7058 20.8373 26.7058C21.768 26.7058 22.3411 26.0783 22.3411 25.0272V4.58848C22.3411 2.26489 21.1308 1.03748 18.8285 1.03748H9.51257C7.2082 1.03748 6 2.26489 6 4.58848V25.0272C6 26.0783 6.57304 26.7058 7.50374 26.7058Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50374 26.7058C8.13702 26.7058 8.53803 26.3755 9.5421 25.3961L14.0693 20.9202C14.1258 20.8637 14.2249 20.8637 14.2718 20.9202L18.8011 25.3982C19.8073 26.3776 20.2019 26.7058 20.8373 26.7058C21.768 26.7058 22.3411 26.0783 22.3411 25.0272V4.58848C22.3411 2.26489 21.1308 1.03748 18.8285 1.03748H9.51257C7.2082 1.03748 6 2.26489 6 4.58848V25.0272C6 26.0783 6.57304 26.7058 7.50374 26.7058ZM8.61444 22.9047C8.45459 23.0645 8.27131 23.0134 8.27131 22.7875V4.71317C8.27131 3.77707 8.7417 3.30879 9.69491 3.30879H18.6558C19.5994 3.30879 20.0698 3.77707 20.0698 4.71317V22.7875C20.0698 23.0134 19.894 23.0645 19.7266 22.9047L14.9351 18.2591C14.4483 17.7904 13.8928 17.7904 13.406 18.2591L8.61444 22.9047Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.84421 24.8972H21.968C24.4974 24.8972 25.8026 23.5919 25.8026 21.0914V6.82921C25.8026 4.32656 24.4974 3.02344 21.968 3.02344H5.84421C3.31484 3.02344 2 4.31695 2 6.82921V21.0914C2 23.6016 3.31484 24.8972 5.84421 24.8972ZM5.81186 22.6013C4.82795 22.6013 4.29592 22.0969 4.29592 21.0661V10.2389C4.29592 9.21772 4.82795 8.70585 5.81186 8.70585H21.9812C22.9651 8.70585 23.5067 9.21772 23.5067 10.2389V21.0661C23.5067 22.0969 22.9651 22.6013 21.9812 22.6013H5.81186ZM11.6516 12.7673H12.343C12.7594 12.7673 12.8947 12.6438 12.8947 12.2273V11.5359C12.8947 11.1194 12.7594 10.9863 12.343 10.9863H11.6516C11.2351 10.9863 11.0902 11.1194 11.0902 11.5359V12.2273C11.0902 12.6438 11.2351 12.7673 11.6516 12.7673ZM15.481 12.7673H16.1724C16.5889 12.7673 16.7359 12.6438 16.7359 12.2273V11.5359C16.7359 11.1194 16.5889 10.9863 16.1724 10.9863H15.481C15.0645 10.9863 14.9197 11.1194 14.9197 11.5359V12.2273C14.9197 12.6438 15.0645 12.7673 15.481 12.7673ZM19.3126 12.7673H20.004C20.4205 12.7673 20.5653 12.6438 20.5653 12.2273V11.5359C20.5653 11.1194 20.4205 10.9863 20.004 10.9863H19.3126C18.8961 10.9863 18.7609 11.1194 18.7609 11.5359V12.2273C18.7609 12.6438 18.8961 12.7673 19.3126 12.7673ZM7.8221 16.5382H8.50178C8.92999 16.5382 9.06522 16.4147 9.06522 15.9982V15.3068C9.06522 14.8903 8.92999 14.7668 8.50178 14.7668H7.8221C7.39389 14.7668 7.25866 14.8903 7.25866 15.3068V15.9982C7.25866 16.4147 7.39389 16.5382 7.8221 16.5382ZM11.6516 16.5382H12.343C12.7594 16.5382 12.8947 16.4147 12.8947 15.9982V15.3068C12.8947 14.8903 12.7594 14.7668 12.343 14.7668H11.6516C11.2351 14.7668 11.0902 14.8903 11.0902 15.3068V15.9982C11.0902 16.4147 11.2351 16.5382 11.6516 16.5382ZM15.481 16.5382H16.1724C16.5889 16.5382 16.7359 16.4147 16.7359 15.9982V15.3068C16.7359 14.8903 16.5889 14.7668 16.1724 14.7668H15.481C15.0645 14.7668 14.9197 14.8903 14.9197 15.3068V15.9982C14.9197 16.4147 15.0645 16.5382 15.481 16.5382ZM19.3126 16.5382H20.004C20.4205 16.5382 20.5653 16.4147 20.5653 15.9982V15.3068C20.5653 14.8903 20.4205 14.7668 20.004 14.7668H19.3126C18.8961 14.7668 18.7609 14.8903 18.7609 15.3068V15.9982C18.7609 16.4147 18.8961 16.5382 19.3126 16.5382ZM7.8221 20.3187H8.50178C8.92999 20.3187 9.06522 20.1877 9.06522 19.7691V19.0798C9.06522 18.6612 8.92999 18.5398 8.50178 18.5398H7.8221C7.39389 18.5398 7.25866 18.6612 7.25866 19.0798V19.7691C7.25866 20.1877 7.39389 20.3187 7.8221 20.3187ZM11.6516 20.3187H12.343C12.7594 20.3187 12.8947 20.1877 12.8947 19.7691V19.0798C12.8947 18.6612 12.7594 18.5398 12.343 18.5398H11.6516C11.2351 18.5398 11.0902 18.6612 11.0902 19.0798V19.7691C11.0902 20.1877 11.2351 20.3187 11.6516 20.3187ZM15.481 20.3187H16.1724C16.5889 20.3187 16.7359 20.1877 16.7359 19.7691V19.0798C16.7359 18.6612 16.5889 18.5398 16.1724 18.5398H15.481C15.0645 18.5398 14.9197 18.6612 14.9197 19.0798V19.7691C14.9197 20.1877 15.0645 20.3187 15.481 20.3187Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.84421 21.8972H18.0295C20.5685 21.8972 21.8737 20.5919 21.8737 18.0914V3.82921C21.8737 1.32656 20.5685 0.0234375 18.0295 0.0234375H3.84421C1.31484 0.0234375 0 1.31695 0 3.82921V18.0914C0 20.6016 1.31484 21.8972 3.84421 21.8972Z" fill="#aeaeaf"/>
<path d="M8.24921 16.3608C7.44976 16.3608 7.04688 15.8618 7.04688 15.0368V6.67026C7.04688 5.84948 7.45187 5.34839 8.24921 5.34839H13.795C14.3777 5.34839 14.7607 5.68026 14.7607 6.26643C14.7607 6.8376 14.3777 7.19619 13.795 7.19619H9.33695V9.92808H13.5377C14.0824 9.92808 14.4464 10.2356 14.4464 10.7923C14.4464 11.3222 14.0824 11.6255 13.5377 11.6255H9.33695V14.513H13.795C14.3777 14.513 14.7607 14.8545 14.7607 15.4406C14.7607 16.0118 14.3777 16.3608 13.795 16.3608H8.24921Z" fill="#111111"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 27 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.84421 21.825H23.3062C25.541 21.825 26.8558 20.5198 26.8558 18.0192V5.96718C26.8558 3.46664 25.5314 2.16141 23.0116 2.16141H12.1076C11.2706 2.16141 10.7627 1.96758 10.1219 1.43625L9.44928 0.892499C8.62944 0.217265 8.02335 0 6.79569 0H3.47905C1.29539 0 0 1.27523 0 3.73405V18.0192C0 20.5294 1.31484 21.825 3.84421 21.825ZM3.97733 19.5291C2.88772 19.5291 2.29592 18.967 2.29592 17.8263V3.93233C2.29592 2.85655 2.88209 2.28631 3.93538 2.28631H6.195C7.01273 2.28631 7.50468 2.48764 8.15929 3.02108L8.82984 3.57444C9.64429 4.23256 10.2696 4.45944 11.4973 4.45944H22.8689C23.9489 4.45944 24.5599 5.03108 24.5599 6.16968V17.8359C24.5599 18.967 23.9489 19.5291 22.8689 19.5291H3.97733ZM1.43319 8.87716H25.432V6.85241H1.43319V8.87716Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 840 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 14.0029C2 17.5576 3.03008 21.4841 4.76539 24.5308C5.10476 25.1148 5.7589 25.285 6.36851 24.9477C6.94717 24.6318 7.11311 23.9755 6.76413 23.3425C5.20647 20.5042 4.336 17.132 4.336 14.0029C4.336 7.60233 8.2496 3.336 14.1259 3.336C19.9946 3.336 23.9157 7.60233 23.9157 14.0029C23.9157 17.132 23.0356 20.5042 21.478 23.3425C21.129 23.9755 21.2949 24.6318 21.8736 24.9477C22.4832 25.285 23.1469 25.1148 23.4767 24.5308C25.212 21.4841 26.2517 17.5576 26.2517 14.0029C26.2517 6.1907 21.4137 1 14.1259 1C6.82836 1 2 6.1907 2 14.0029ZM5.9485 24.2188C6.36686 25.6108 7.57225 26.2424 8.97803 25.8262C10.3721 25.4036 11.0155 24.1811 10.5854 22.787L8.90115 17.2223C8.4828 15.8399 7.27952 15.1966 5.87374 15.6128C4.47968 16.0429 3.83632 17.2579 4.2664 18.6637L5.9485 24.2188ZM22.2936 24.2188L23.9757 18.6637C24.4058 17.2483 23.772 16.0429 22.3684 15.6128C20.9626 15.1966 19.7689 15.8399 19.3409 17.2223L17.6567 22.787C17.2266 24.1907 17.87 25.4036 19.2641 25.8262C20.6795 26.2424 21.8752 25.6108 22.2936 24.2188Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 5.55171V23.1659C5 25.501 6.20703 26.7176 8.52288 26.7176H20.078C22.3939 26.7176 23.5892 25.501 23.5892 23.1659V5.55171C23.5892 3.21664 22.3939 2 20.078 2H8.52288C6.20703 2 5 3.21664 5 5.55171ZM7.29592 5.68483C7.29592 4.7703 7.75975 4.29592 8.69444 4.29592H19.9044C20.8391 4.29592 21.2933 4.7703 21.2933 5.68483V23.0328C21.2933 23.9377 20.8294 24.4217 19.9044 24.4217H8.69444C7.75975 24.4217 7.29592 23.9377 7.29592 23.0328V5.68483ZM14.3005 22.6447C16.9215 22.6447 19.0332 20.5447 19.0332 17.9141C19.0332 15.2771 16.9215 13.1909 14.3005 13.1813C11.6794 13.1675 9.56772 15.2771 9.56772 17.9141C9.56772 20.5447 11.6794 22.6447 14.3005 22.6447ZM14.3005 19.8043C13.261 19.8043 12.406 18.961 12.406 17.9141C12.406 16.8277 13.2301 16.0079 14.3005 16.0079C15.3591 16.0079 16.1928 16.8277 16.1928 17.9141C16.1928 18.961 15.3591 19.8043 14.3005 19.8043ZM14.2984 11.4596C15.7927 11.4596 17.0283 10.257 17.0049 8.75303C16.9932 7.24905 15.7927 6.0678 14.2984 6.05397C12.7944 6.04436 11.5939 7.23944 11.5939 8.75303C11.5939 10.257 12.7944 11.4596 14.2984 11.4596ZM14.3005 10.0069C13.5978 10.0069 13.0465 9.43436 13.0465 8.75303C13.0465 8.02694 13.5978 7.49701 14.3005 7.49701C14.9797 7.49701 15.5544 8.05787 15.5544 8.75303C15.5544 9.43436 14.9914 10.0069 14.3005 10.0069Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.105 24.21C18.7369 24.21 24.2121 18.7273 24.2121 12.105C24.2121 5.47312 18.7273 0 12.0954 0C5.47523 0 0 5.47312 0 12.105C0 18.7273 5.48484 24.21 12.105 24.21ZM12.105 21.8255C6.71085 21.8255 2.39412 17.4991 2.39412 12.105C2.39412 6.71085 6.70124 2.38452 12.0954 2.38452C17.4895 2.38452 21.8276 6.71085 21.8276 12.105C21.8276 17.4991 17.4991 21.8255 12.105 21.8255Z" fill="currentColor"/>
<path d="M12.7392 17.4328C13.4478 17.4328 13.872 16.9985 13.872 16.2073V7.98889C13.872 7.21851 13.4032 6.76124 12.646 6.76124C12.1428 6.76124 11.7727 6.89741 11.2172 7.27312L9.18593 8.64163C8.87328 8.86077 8.73828 9.07663 8.73828 9.42444C8.73828 9.85733 9.06172 10.2373 9.5021 10.2373C9.71281 10.2373 9.84476 10.2117 10.1417 10.0073L11.5416 9.08295H11.6396V16.2073C11.6396 16.9985 12.0596 17.4328 12.7392 17.4328Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 916 B

View File

@@ -0,0 +1,11 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="currentColor" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M14.131 22.948l-1.172-3.193c0 0-1.912 2.131-4.771 2.131-2.537 0-4.333-2.203-4.333-5.729 0-4.511 2.276-6.125 4.515-6.125 3.224 0 4.245 2.089 5.125 4.772l1.161 3.667c1.161 3.561 3.365 6.421 9.713 6.421 4.548 0 7.631-1.391 7.631-5.068 0-2.968-1.697-4.511-4.844-5.244l-2.344-0.511c-1.624-0.371-2.104-1.032-2.104-2.131 0-1.249 0.985-1.984 2.604-1.984 1.767 0 2.704 0.661 2.865 2.24l3.661-0.444c-0.297-3.301-2.584-4.656-6.323-4.656-3.308 0-6.532 1.251-6.532 5.245 0 2.5 1.204 4.077 4.245 4.807l2.484 0.589c1.865 0.443 2.484 1.224 2.484 2.287 0 1.359-1.323 1.921-3.828 1.921-3.703 0-5.244-1.943-6.124-4.625l-1.204-3.667c-1.541-4.765-4.005-6.531-8.891-6.531-5.287-0.016-8.151 3.385-8.151 9.192 0 5.573 2.864 8.595 8.005 8.595 4.14 0 6.125-1.943 6.125-1.943z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.42607 18.5857L16.593 5.42412L14.344 3.16546L1.1674 16.3366L0.0267015 19.0816C-0.10197 19.4303 0.258496 19.8049 0.592479 19.6708L3.42607 18.5857ZM17.715 4.32139L18.9829 3.07476C19.6122 2.44546 19.6378 1.7482 19.0703 1.16906L18.6128 0.709452C18.0454 0.139922 17.3439 0.200625 16.7125 0.808593L15.4467 2.06273L17.715 4.32139Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="30" viewBox="0 0 28 30" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 30" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.367 13.6066C22.377 13.5853 23.1931 12.7884 23.1931 11.7944C23.1931 10.7887 22.3749 9.97054 21.367 9.95671C20.3613 9.945 19.5314 10.7866 19.5314 11.7944C19.5314 12.7905 20.3613 13.6205 21.367 13.6066ZM21.367 18.829C22.3727 18.829 23.1931 17.997 23.1931 16.9891C23.1931 15.9813 22.3749 15.1631 21.367 15.1631C20.3613 15.1631 19.5314 15.9855 19.5314 16.9891C19.5314 17.9948 20.3613 18.829 21.367 18.829ZM11.1511 11.3658C11.6283 11.3658 12.0117 10.9631 12.0117 10.4625C12.0117 9.98742 11.6262 9.59226 11.1511 9.59226C10.6739 9.59226 10.2787 9.98742 10.2787 10.4625C10.2787 10.9631 10.6739 11.3658 11.1511 11.3658ZM13.9634 12.1671C14.4598 12.1671 14.8357 11.7644 14.8357 11.2852C14.8357 10.7866 14.4619 10.4032 13.9634 10.4032C13.4841 10.4032 13.1145 10.7866 13.1145 11.2852C13.1145 11.7644 13.4862 12.1671 13.9634 12.1671ZM16.0873 14.2024C16.5687 14.2024 16.9734 13.819 16.9734 13.3418C16.9734 12.8412 16.5687 12.4364 16.0873 12.4364C15.6122 12.4364 15.2309 12.8412 15.2309 13.3418C15.2309 13.819 15.6122 14.2024 16.0873 14.2024ZM16.8087 16.9327C17.2859 16.9327 17.6693 16.5396 17.6693 16.0624C17.6693 15.5756 17.2859 15.1922 16.8087 15.1922C16.3219 15.1922 15.9384 15.5756 15.9384 16.0624C15.9384 16.5396 16.3219 16.9327 16.8087 16.9327ZM16.0873 19.6767C16.5666 19.6767 16.9734 19.272 16.9734 18.7831C16.9734 18.2963 16.5687 17.9128 16.0873 17.9128C15.6122 17.9128 15.2309 18.2963 15.2309 18.7831C15.2309 19.2741 15.6122 19.6767 16.0873 19.6767ZM13.9634 21.7697C14.4619 21.7697 14.8357 21.3766 14.8357 20.8877C14.8357 20.4084 14.4598 20.0037 13.9634 20.0037C13.4862 20.0037 13.1145 20.4084 13.1145 20.8877C13.1145 21.3766 13.4841 21.7697 13.9634 21.7697ZM11.1511 22.6287C11.6262 22.6287 12.0117 22.2335 12.0117 21.7584C12.0117 21.2578 11.6283 20.8531 11.1511 20.8531C10.6739 20.8531 10.2787 21.2578 10.2787 21.7584C10.2787 22.2335 10.6739 22.6287 11.1511 22.6287ZM8.3271 21.7697C8.80429 21.7697 9.17601 21.3766 9.17601 20.8877C9.17601 20.4084 8.80218 20.0037 8.3271 20.0037C7.82859 20.0037 7.45476 20.4084 7.45476 20.8877C7.45476 21.3766 7.82648 21.7697 8.3271 21.7697ZM6.21069 19.6767C6.68577 19.6767 7.0596 19.2741 7.0596 18.7831C7.0596 18.2963 6.68577 17.9128 6.21069 17.9128C5.7239 17.9128 5.31913 18.2963 5.31913 18.7831C5.31913 19.272 5.7239 19.6767 6.21069 19.6767ZM5.4914 16.9327C5.96858 16.9327 6.35202 16.5396 6.35202 16.0624C6.35202 15.5756 5.96858 15.1922 5.4914 15.1922C5.0121 15.1922 4.62116 15.5756 4.62116 16.0624C4.62116 16.5396 5.0121 16.9327 5.4914 16.9327ZM6.21069 14.2024C6.68577 14.2024 7.0596 13.819 7.0596 13.3418C7.0596 12.8412 6.68577 12.4364 6.21069 12.4364C5.7239 12.4364 5.31913 12.8412 5.31913 13.3418C5.31913 13.819 5.7239 14.2024 6.21069 14.2024ZM8.3271 12.1671C8.80218 12.1671 9.17601 11.7644 9.17601 11.2852C9.17601 10.7866 8.80429 10.4032 8.3271 10.4032C7.82648 10.4032 7.45476 10.7866 7.45476 11.2852C7.45476 11.7644 7.82859 12.1671 8.3271 12.1671ZM11.1511 14.2601C11.6283 14.2601 12.0117 13.867 12.0117 13.3898C12.0117 12.8988 11.6283 12.4941 11.1511 12.4941C10.6739 12.4941 10.2787 12.8988 10.2787 13.3898C10.2787 13.867 10.6739 14.2601 11.1511 14.2601ZM13.7728 15.4003C14.2596 15.4003 14.6334 14.9955 14.6334 14.528C14.6334 14.0508 14.2596 13.6556 13.7728 13.6556C13.2977 13.6556 12.8909 14.0529 12.8909 14.528C12.8909 14.9934 13.2977 15.4003 13.7728 15.4003ZM13.7728 18.5674C14.2617 18.5674 14.6334 18.1819 14.6334 17.7047C14.6334 17.2062 14.2617 16.8131 13.7728 16.8131C13.2956 16.8131 12.8909 17.2062 12.8909 17.7047C12.8909 18.1819 13.2956 18.5674 13.7728 18.5674ZM11.1511 19.7248C11.6262 19.7248 12.0117 19.3179 12.0117 18.8311C12.0117 18.3539 11.6283 17.9609 11.1511 17.9609C10.6739 17.9609 10.2787 18.3539 10.2787 18.8311C10.2787 19.3179 10.6739 19.7248 11.1511 19.7248ZM8.51554 18.5674C8.99273 18.5674 9.39749 18.1819 9.39749 17.7047C9.39749 17.2062 8.99273 16.8131 8.51554 16.8131C8.03835 16.8131 7.65492 17.2062 7.65492 17.7047C7.65492 18.1819 8.03835 18.5674 8.51554 18.5674ZM8.51554 15.4003C8.99062 15.4003 9.39749 14.9934 9.39749 14.528C9.39749 14.0529 8.99062 13.6556 8.51554 13.6556C8.04046 13.6556 7.65492 14.0508 7.65492 14.528C7.65492 14.9955 8.04046 15.4003 8.51554 15.4003ZM11.1511 16.9807C11.6283 16.9807 12.0117 16.5877 12.0117 16.1105C12.0117 15.6333 11.6283 15.2402 11.1511 15.2402C10.6739 15.2402 10.2787 15.6333 10.2787 16.1105C10.2787 16.5877 10.6739 16.9807 11.1511 16.9807ZM22.444 7.38796L23.0962 5.33649L6.82734 0.0527401C6.26344 -0.132415 5.64844 0.186802 5.47711 0.746487C5.28985 1.30406 5.60696 1.91906 6.16664 2.10632L22.444 7.38796ZM3.84421 27.0553H23.9109C26.4499 27.0553 27.7552 25.7597 27.7552 23.2591V8.98734C27.7552 6.48469 26.4499 5.18157 23.9109 5.18157H3.84421C1.31484 5.18157 0 6.48469 0 8.98734V23.2591C0 25.7597 1.31484 27.0553 3.84421 27.0553ZM3.97733 24.7594C2.88772 24.7594 2.29592 24.1952 2.29592 23.0566V9.18773C2.29592 8.04913 2.88772 7.47749 3.97733 7.47749H23.7778C24.8578 7.47749 25.4592 8.04913 25.4592 9.18773V23.0566C25.4592 24.1952 24.8578 24.7594 23.7778 24.7594H3.97733Z"
fill="currentColor" />

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.6785 26.9414C16.0401 26.9414 16.3113 26.6787 16.3762 26.3053C17.2167 19.9648 18.1085 19.0015 24.3609 18.3047C24.746 18.2632 25.0151 17.9782 25.0151 17.6048C25.0151 17.2453 24.7502 16.9624 24.363 16.9146C18.1127 16.1925 17.2462 15.2566 16.3762 8.90648C16.3071 8.53312 16.038 8.28 15.6785 8.28C15.319 8.28 15.0478 8.53312 14.9925 8.90648C14.1499 15.2566 13.2506 16.2199 7.00569 16.9146C6.61101 16.9561 6.34406 17.2411 6.34406 17.6048C6.34406 17.9761 6.6089 18.259 7.00359 18.3047C13.2368 19.1367 14.0819 19.9648 14.9925 26.3053C15.0499 26.6787 15.3211 26.9414 15.6785 26.9414ZM7.575 13.9509C7.81664 13.9509 7.99218 13.7817 8.01984 13.5476C8.43539 10.444 8.52609 10.4334 11.7584 9.82499C11.9808 9.78562 12.1479 9.62179 12.1479 9.38015C12.1479 9.14601 11.9787 8.97047 11.7541 8.94492C8.53242 8.49422 8.42578 8.39507 8.01984 5.23195C7.99218 4.98609 7.81875 4.81687 7.575 4.81687C7.33875 4.81687 7.16531 4.98609 7.13016 5.24578C6.75726 8.34515 6.60258 8.34398 3.39164 8.94492C3.16711 8.98429 3 9.14601 3 9.38015C3 9.63562 3.16711 9.78562 3.44273 9.82499C6.61758 10.328 6.75726 10.4163 7.13016 13.5241C7.16531 13.7817 7.33875 13.9509 7.575 13.9509ZM13.2574 5.76398C13.4203 5.76398 13.5171 5.65758 13.5427 5.5064C13.8794 3.6164 13.8485 3.54164 15.8791 3.17203C16.0324 3.13476 16.1367 3.04219 16.1367 2.87719C16.1367 2.7218 16.0303 2.6175 15.877 2.59195C13.8485 2.24344 13.8773 2.16445 13.5427 0.259686C13.5171 0.106406 13.4203 0 13.2574 0C13.0924 0 12.9977 0.106406 12.9722 0.263905C12.6417 2.14758 12.6684 2.22234 10.6357 2.59195C10.4707 2.61961 10.3781 2.7218 10.3781 2.87719C10.3781 3.04219 10.4707 3.13476 10.642 3.17203C12.6621 3.52898 12.6354 3.60797 12.9722 5.5043C12.9977 5.65758 13.0924 5.76398 13.2574 5.76398Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.105 24.21C18.7622 24.21 24.2121 18.7483 24.2121 12.105C24.2121 5.45203 18.7718 0 12.1146 0C11.3986 0 10.999 0.425623 10.999 1.12359V5.44992C10.999 6.04781 11.4138 6.5032 11.9979 6.5032C12.5937 6.5032 13.0064 6.04781 13.0064 5.44992V1.07062L11.9899 2.3592C17.4581 2.30436 21.8159 6.66866 21.8159 12.105C21.8159 17.4907 17.5076 21.8255 12.105 21.8255C6.70452 21.8255 2.37491 17.4907 2.38452 12.105C2.39412 9.77155 3.21397 7.62608 4.58718 5.96459C5.021 5.36413 5.08452 4.7353 4.59022 4.23656C4.10437 3.74414 3.30586 3.79617 2.78368 4.45944C1.06149 6.54327 0 9.22288 0 12.105C0 18.7483 5.45953 24.21 12.105 24.21Z" fill="currentColor"/>
<path d="M13.9884 13.8957C14.9564 12.883 14.7562 11.5084 13.605 10.7244L7.6315 6.60785C6.93588 6.13653 6.2569 6.83145 6.72611 7.51324L10.833 13.4868C11.6266 14.6401 13.0012 14.8499 13.9884 13.8957Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -1,272 +1,276 @@
$g-border: solid 1px $gray5;
#app-grid {
display: grid;
// grid-template-columns: min-content 1fr 29rem;
grid-template-columns: min-content 1fr;
grid-template-rows: $navheight 1fr 5rem;
grid-template-areas:
"l-sidebar nav"
"l-sidebar content"
"bottombar bottombar";
height: 100%;
border: $g-border;
border-top: none;
border-bottom: none;
margin: 0 auto;
position: relative;
#contentresizer {
margin: 0 $padright 0 $padleft;
}
@include allPhones {
grid-template-columns: 1fr;
display: grid;
// grid-template-columns: min-content 1fr 29rem;
grid-template-columns: min-content 1fr;
grid-template-rows: $navheight 1fr 5.125rem;
grid-template-areas:
"nav"
"content"
"bottombar";
grid-template-rows: auto 1fr auto;
}
'l-sidebar nav'
'l-sidebar content'
'bottombar bottombar';
height: 100%;
border: $g-border;
border-top: none;
border-bottom: none;
margin: 0 auto;
position: relative;
#contentresizer {
margin: 0 $padright 0 $padleft;
}
@include allPhones {
grid-template-columns: 1fr;
grid-template-areas:
'nav'
'content'
'bottombar';
grid-template-rows: auto 1fr auto;
}
}
#acontent {
width: 100%;
grid-area: content;
overflow: hidden;
margin-right: $margright;
width: 100%;
grid-area: content;
overflow: hidden;
margin-right: $margright;
}
.topnav {
grid-area: nav;
height: $navheight;
padding: 1rem $padleft;
padding-right: $padright;
grid-area: nav;
height: $navheight;
padding: 1rem $padleft;
padding-right: $padright;
@include allPhones {
display: flex;
gap: $small;
height: unset;
padding: 6px 8px;
margin: $medium 1rem;
border-radius: 5rem;
background-color: $gray;
}
@include allPhones {
display: flex;
gap: $smaller;
height: unset;
padding: 6px 8px;
margin: $medium 1rem;
border-radius: 5rem;
background-color: $gray;
}
}
.b-bar {
grid-area: bottombar;
border-top: $g-border;
grid-area: bottombar;
border-top: $g-border;
}
.content-page {
scrollbar-gutter: stable;
padding-left: $padleft;
padding-right: $padright;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
padding-left: $padleft;
padding-right: $padright;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
}
.vue-recycle-scroller__item-wrapper {
overflow: visible !important;
overflow: visible !important;
}
.vue-recycle-scroller {
scrollbar-gutter: stable;
padding-left: $padleft;
scrollbar-gutter: stable;
padding-left: $padleft;
}
.r-sidebar {
grid-area: r-sidebar;
border-left: $g-border;
grid-area: r-sidebar;
border-left: $g-border;
.vue-recycle-scroller {
padding-left: 0;
}
.vue-recycle-scroller {
padding-left: 0;
}
}
// ====== MODIFIERS =======
#app-grid.is_alt_layout {
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr 5rem;
grid-template-areas:
"nav"
"content"
"bottombar";
@include allPhones {
grid-template-columns: 1fr !important;
grid-template-rows: max-content 1fr 9.5rem !important;
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr 5.125rem;
grid-template-areas:
"nav"
"content"
"bottombar" !important;
}
'nav'
'content'
'bottombar';
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.topnav {
background-color: $gray;
}
.vue-recycle-scroller,
.content-page {
padding-top: 2rem;
}
.search-page-top-results {
padding-bottom: $padbottom;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.lyricsview {
padding-bottom: 2rem;
}
#lyricscontent {
padding-top: 0;
padding-left: 2rem;
padding-right: 2rem;
}
@media only screen and (min-width: 1980px) {
// NOTE: Styles for 1680px and below
$alt_layout_pad: max(2rem, calc((100% - 1680px) / 2));
@include allPhones {
grid-template-columns: 1fr !important;
grid-template-rows: max-content 1fr 9.5rem !important;
grid-template-areas:
'nav'
'content'
'bottombar' !important;
}
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
padding: 0 $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.topnav {
background-color: $gray;
}
.vue-recycle-scroller,
.content-page {
padding-top: 2rem;
}
.search-page-top-results {
padding-bottom: $padbottom;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.lyricsview {
padding-bottom: 2rem;
}
#lyricscontent {
padding-top: 0;
padding-left: 2rem;
padding-right: 2rem;
}
@media only screen and (min-width: 1980px) {
// NOTE: Styles for 1680px and below
$alt_layout_pad: max(2rem, calc((100% - 1680px) / 2));
.vue-recycle-scroller,
.content-page,
.topnav,
#songlist-scroller {
padding-left: $alt_layout_pad;
padding-right: $alt_layout_pad;
}
#contentresizer {
margin: 0 $alt_layout_pad;
}
.search-view .buttons-area {
padding-left: $alt_layout_pad;
}
.b-bar,
.search-page-top-results {
padding: 0 $alt_layout_pad;
}
}
}
}
#app-grid.extendWidth {
padding-right: 0;
border-left: none;
border-right: none;
max-width: 100% !important;
padding-right: 0;
border-left: none;
border-right: none;
max-width: 100% !important;
}
#app-grid.useSidebar {
grid-template-columns: min-content 1fr 28rem;
grid-template-areas:
"l-sidebar nav r-sidebar"
"l-sidebar content r-sidebar"
"bottombar bottombar bottombar";
grid-template-columns: min-content 1fr 28rem;
grid-template-areas:
'l-sidebar nav r-sidebar'
'l-sidebar content r-sidebar'
'bottombar bottombar bottombar';
@include for-desktop-down {
grid-template-columns: min-content 1fr 24rem;
}
@include for-desktop-down {
grid-template-columns: min-content 1fr 24rem;
}
#acontent {
// margin-right: 0 !important;
// padding-right: $medium !important;
}
#acontent {
// margin-right: 0 !important;
// padding-right: $medium !important;
}
}
#app-grid.NoSideBorders {
border-right: none !important;
border-left: none !important;
border-right: none !important;
border-left: none !important;
}
.v-scroll-page {
.scroller {
padding-right: $padright;
height: 100%;
width: 100%;
padding-bottom: $content-padding-bottom;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
.scroller {
padding-right: $padright;
height: 100%;
width: 100%;
padding-bottom: $content-padding-bottom;
padding-bottom: $padbottom;
-webkit-overflow-scrolling: touch;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
@include allPhones {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
}
.song-title > .isSmallArtists {
display: none;
display: none;
}
.isSmall {
.songlist-item {
grid-template-columns: 2fr 5.5rem !important;
// disable hover on mobile
// to prevent tap effect
&:hover {
background-color: unset;
.album_disc_header {
padding-left: $small;
}
@include mediumPhones {
grid-template-columns: 2fr 2.5rem !important;
gap: $small !important;
.songlist-item {
grid-template-columns: 2fr 7.5rem !important;
// disable hover on mobile
// to prevent tap effect
&:hover {
background-color: unset;
}
@include mediumPhones {
grid-template-columns: 2fr 2.5rem !important;
gap: $small !important;
}
}
}
.song-artists,
.song-album {
display: none !important;
}
.song-artists,
.song-album {
display: none !important;
}
.isSmallArtists {
display: flex !important;
align-items: center;
gap: 4px;
font-size: small;
color: $white;
opacity: 0.67;
}
.isSmallArtists {
display: flex !important;
align-items: center;
gap: 4px;
font-size: small;
color: $white;
opacity: 0.67;
}
}
.isMedium {
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 5.5rem;
}
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 7.5rem;
}
.song-album {
display: none !important;
}
.song-album {
display: none !important;
}
}

View File

@@ -175,7 +175,7 @@ button {
}
.spinner {
border: solid 3px rgb(0, 0, 0);
border: solid 3px rgb(221, 217, 217);
border-top: solid 3px transparent;
border-left: solid 3px transparent;
border-radius: 50%;
@@ -244,6 +244,10 @@ button {
&.playlist {
color: $green;
}
&.mix {
color: $lightbrown;
}
}
// Badges used in settings
@@ -263,4 +267,9 @@ button {
.badge.new {
background-color: $blue;
opacity: 1;
}
.explicit-icon {
width: 0.9rem;
margin-left: $smaller;
}

View File

@@ -41,7 +41,9 @@ body {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
height: 100vh;
height: 100dvh;
width: 100vw;
width: 100dvw;
overflow: hidden;
margin: 0;
background-color: $body;

View File

@@ -1,11 +1,20 @@
input[type="number"] {
width: 40px;
padding: 4px 5px;
border-radius: 3px;
input {
font-family: 'SF Compact Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
font-weight: 500;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
input[type="search"] {
font-family: "SF Compact Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
height: 2.25rem !important;
input[type='number'] {
width: 40px;
padding: 4px 5px;
border-radius: 3px;
}
input[type='search'] {
height: 2.25rem !important;
}

View File

@@ -1,7 +1,7 @@
/* Total width */
.designatedOS ::-webkit-scrollbar {
background-color: $body;
width: 14px;
width: 12px;
}
/* Background of the scrollbar except button or resizer */
@@ -13,7 +13,7 @@
.designatedOS ::-webkit-scrollbar-thumb {
background-color: $gray2;
border-radius: 16px;
border: 4px solid $body;
border: 3px solid $body;
}
.designatedOS ::-webkit-scrollbar-thumb:hover {
@@ -73,3 +73,12 @@
.designatedOS .settingsmodalcontent::-webkit-scrollbar-thumb {
border-color: $black;
}
/* Login modal */
.designatedOS .loginmodal .alcontent::-webkit-scrollbar-track {
background-color: $black;
}
.designatedOS .loginmodal .alcontent::-webkit-scrollbar-thumb {
border-color: $black;
}

View File

@@ -40,6 +40,7 @@ $purple: #bf5af2;
$brown: #ac8e68;
$indigo: #5e5ce6;
$teal: rgb(64, 200, 224);
$lightbrown: #ebca89;
$primary: $gray4;
$accent: $gray1;
@@ -60,7 +61,7 @@ $separator: $gray4;
$margright: 0;
$padbottom: 4rem;
$maxwidth: 1438px;
$navheight: 4.75rem;
$navheight: 4.5rem;
$cardwidth: 10.75rem;
$maxpadleft: 5rem;

View File

@@ -11,8 +11,8 @@
</template>
<script setup lang="ts">
import { AlbumDisc } from '@/interfaces'
import PlaySvg from '@/assets/icons/play.svg'
import { AlbumDisc } from '@/interfaces'
defineProps<{
album_disc: AlbumDisc
@@ -45,13 +45,14 @@ defineEmits<{
cursor: pointer;
display: flex;
align-items: center;
transition: opacity 0.2s ease-out;
svg {
height: 12px;
}
}
@include largePhones {
@media only screen and (max-width: 724px) {
padding-left: 0.5rem !important;
}

View File

@@ -45,15 +45,15 @@ const color = computed(() => {
return props.source === "album" ? album.colors.btn : "";
});
const hookAction = async () => {
if (props.source === "album") {
// fetch data to be used in the component below this one.
await album.fetchArtistAlbums();
return;
}
};
// const hookAction = async () => {
// if (props.source === "album") {
// // fetch data to be used in the component below this one.
// await album.fetchArtistAlbums();
// return;
// }
// };
onMounted(hookAction);
// onMounted(hookAction);
</script>
<style lang="scss">

View File

@@ -5,9 +5,7 @@
:style="{
boxShadow:
// hide shadow on small screen
isSmallPhone ? '' : colors.bg
? `0 .5rem 2rem ${colors.bg}`
: '0 .5rem 2rem black',
isSmallPhone ? '' : colors.bg ? `0 .5rem 2rem ${colors.bg}` : '0 .5rem 2rem black',
}"
></div>
<div
@@ -18,14 +16,8 @@
background: isSmallPhone ? '' : colors.bg ? colors.bg : '',
}"
>
<div
class="big-img no-scroll"
:class="`${isHeaderSmall ? 'imgSmall' : ''} shadow-lg rounded-sm`"
>
<img
:src="imguri.thumb.large + album.image"
class="rounded-sm"
/>
<div class="big-img no-scroll" :class="`${isHeaderSmall ? 'imgSmall' : ''} shadow-lg rounded-sm`">
<img :src="imguri.thumb.large + album.image" class="rounded-sm" />
</div>
<Info />
</div>
@@ -135,6 +127,10 @@ useVisibility(albumheaderthing, handleVisibilityState)
}
}
.albumtype {
text-align: center;
}
.title {
font-size: 1.5rem !important;
max-width: fit-content;

View File

@@ -24,7 +24,6 @@ const update = async () => {
};
onMounted(async () => {
console.log("mounted");
props.fetch_callback().then(update);
});

View File

@@ -1,170 +1,177 @@
<template>
<div
v-if="!on_sidebar"
class="artist-header-ambient rounded-lg"
:class="{ isSmallPhone }"
style="height: 100%; width: 100%"
:style="{
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
}"
></div>
<div
ref="artistheader"
class="artist-page-header rounded-lg no-scroll"
:class="{ isSmallPhone, useCircularImage }"
:style="{
height: `${isSmallPhone ? '25rem' : containerHeight}`,
}"
>
<Info :artist="artist" :use-circular-image="useCircularImage" />
<div
class="artist-img no-select"
:style="{
height: containerHeight,
}"
>
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
<div class="headparent">
<div
v-if="!on_sidebar"
class="artist-header-ambient rounded-lg"
:class="{ isSmallPhone }"
:style="{
boxShadow: !useCircularImage ? (colors.bg.length ? `0 .5rem 2rem ${colors.bg}` : undefined) : undefined,
}"
></div>
<div
ref="artistheader"
class="artist-page-header rounded-lg no-scroll"
:class="{ isSmallPhone, useCircularImage }"
:style="{
height: `${isSmallPhone ? '25rem' : containerHeight}`,
}"
>
<Info :artist="artist" :use-circular-image="useCircularImage" />
<div
class="artist-img no-select"
:style="{
height: containerHeight,
}"
>
<img id="artist-avatar" :src="paths.images.artist.large + artist.image" @load="store.setBgColor" />
</div>
<div
v-if="!useCircularImage"
class="gradient"
:style="{
backgroundImage: colors.bg
? `linear-gradient(${gradientDirection}, transparent ${
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
}%,
${colors.bg} ${gradientWidth}%,
${colors.bg} 100%)`
: '',
}"
></div>
</div>
</div>
<div
v-if="!useCircularImage"
class="gradient"
:style="{
backgroundImage: colors.bg
? `linear-gradient(${gradientDirection}, transparent ${
isSmall ? 60 : gradientTransparentWidth - (width < 700 ? 40 : width < 900 ? 20 : 10)
}%,
${colors.bg} ${gradientWidth}%,
${colors.bg} 100%)`
: '',
}"
></div>
</div>
</template>
<script setup lang="ts">
import useSettingsStore from "@/stores/settings";
import { useElementSize } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { Ref, computed, onMounted, ref } from "vue";
import { onBeforeRouteUpdate } from "vue-router";
import useSettingsStore from '@/stores/settings'
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { Ref, computed, onMounted, ref } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'
import { paths } from "@/config";
import updatePageTitle from "@/utils/updatePageTitle";
import { paths } from '@/config'
import updatePageTitle from '@/utils/updatePageTitle'
import { isSmall } from "@/stores/content-width";
import useArtistStore from "@/stores/pages/artist";
import Info from "./HeaderComponents/Info.vue";
import { isSmall } from '@/stores/content-width'
import useArtistStore from '@/stores/pages/artist'
import Info from './HeaderComponents/Info.vue'
const image_width_px = 450;
const store = useArtistStore();
const settings = useSettingsStore();
const image_width_px = 450
const store = useArtistStore()
const settings = useSettingsStore()
const props = defineProps<{
on_sidebar?: boolean;
}>();
on_sidebar?: boolean
}>()
const { info: artist, colors } = storeToRefs(store);
const { info: artist, colors } = storeToRefs(store)
function updateTitle() {
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name);
props.on_sidebar ? () => {} : updatePageTitle(artist.value.name)
}
onMounted(() => updateTitle());
onBeforeRouteUpdate(() => updateTitle());
onMounted(() => updateTitle())
onBeforeRouteUpdate(() => updateTitle())
const artistheader: Ref<HTMLElement | null> = ref(null);
const { width } = useElementSize(artistheader);
const artistheader: Ref<HTMLElement | null> = ref(null)
const { width } = useElementSize(artistheader)
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100));
const gradientTransparentWidth = computed(() => Math.floor((image_width_px / (width.value || 1)) * 100))
const isSmallPhone = computed(() => width.value <= 660);
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg);
const isSmallPhone = computed(() => width.value <= 660)
const useCircularImage = computed(() => !isSmallPhone.value && settings.useCircularArtistImg)
const gradientDirection = computed(() => (isSmallPhone.value ? "210deg" : "to left"));
const gradientDirection = computed(() => (isSmallPhone.value ? '210deg' : 'to left'))
const gradientWidth = computed(() => {
return isSmallPhone.value ? "80" : Math.min(gradientTransparentWidth.value, 50);
});
return isSmallPhone.value ? '80' : Math.min(gradientTransparentWidth.value, 50)
})
const containerHeight = computed(() => {
return useCircularImage.value ? "13rem" : "18rem";
});
return useCircularImage.value ? '13rem' : '18rem'
})
</script>
<style lang="scss">
.headparent {
height: 100%;
width: 100%;
position: relative;
}
.artist-header-ambient {
height: 17rem;
position: absolute;
opacity: 0.25;
margin-right: -1rem;
height: 18rem;
width: 100%;
position: absolute;
opacity: 0.25;
}
.artist-page-header {
display: grid;
grid-template-columns: 1fr 450px;
position: relative;
.artist-img {
display: flex;
align-items: flex-end;
order: 1;
img {
height: 100%;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
object-position: 0% 20%;
}
}
&.useCircularImage {
grid-template-columns: min-content 1fr;
.artist-img {
padding: 1rem;
order: -1;
z-index: 10;
img {
border-radius: 50%;
height: calc(100% - 0rem);
width: unset;
aspect-ratio: 1;
}
}
}
.gradient {
position: absolute;
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
height: 100%;
width: 100%;
&.isSmallPhone {
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
}
}
&.isSmallPhone {
display: flex;
flex-direction: column-reverse;
display: grid;
grid-template-columns: 1fr 450px;
position: relative;
.artist-img {
position: absolute;
width: 100%;
top: 0;
height: 100% !important;
display: flex;
align-items: flex-end;
order: 1;
img {
img {
height: 100%;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
object-position: 0% 20%;
}
}
&.useCircularImage {
grid-template-columns: min-content 1fr;
.artist-img {
padding: 1rem;
order: -1;
z-index: 10;
img {
border-radius: 50%;
height: calc(100% - 0rem);
width: unset;
aspect-ratio: 1;
}
}
}
.gradient {
position: absolute;
background-image: linear-gradient(to left, transparent 10%, $gray 50%, $gray 100%);
height: 100%;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
object-position: 0% 20%;
}
&.isSmallPhone {
background-image: linear-gradient(210deg, transparent 20%, $gray 80%, $gray 100%);
}
}
&.isSmallPhone {
display: flex;
flex-direction: column-reverse;
position: relative;
.artist-img {
position: absolute;
width: 100%;
top: 0;
height: 100% !important;
img {
height: 100%;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
object-position: 0% 20%;
}
}
}
}
}
</style>

View File

@@ -1,63 +1,67 @@
<template>
<div class="artist-top-tracks">
<h3 class="section-title">
{{ title }}
<SeeAll :route="route" />
</h3>
<div class="tracks" :class="{ isSmall, isMedium }">
<SongItem
v-for="(song, index) in tracks"
:key="index"
:track="song"
:index="total ? total - index : index + 1"
:source="source"
@playThis="playHandler(index)"
/>
<div class="artist-top-tracks">
<h3 class="section-title" :class="{ isSmall, isMedium }">
{{ title }}
<SeeAll :route="route" />
</h3>
<div class="tracks" :class="{ isSmall, isMedium }">
<SongItem
v-for="(song, index) in tracks"
:key="index"
:track="song"
:index="total ? total - index : index + 1"
:source="source"
@playThis="playHandler(index)"
/>
</div>
<div v-if="!tracks.length" class="error">No tracks</div>
</div>
<div v-if="!tracks.length" class="error">No tracks</div>
</div>
</template>
<script setup lang="ts">
import { dropSources } from "@/enums";
import { Track } from "@/interfaces";
import { isMedium, isSmall } from "@/stores/content-width";
import SeeAll from "../shared/SeeAll.vue";
import SongItem from "../shared/SongItem.vue";
import { dropSources } from '@/enums'
import { Track } from '@/interfaces'
import { isMedium, isSmall } from '@/stores/content-width'
import SeeAll from '../shared/SeeAll.vue'
import SongItem from '../shared/SongItem.vue'
defineProps<{
tracks: Track[];
route: string;
title: string;
playHandler: (index: number) => void;
source: dropSources;
total?: number;
}>();
tracks: Track[]
route: string
title: string
playHandler: (index: number) => void
source: dropSources
total?: number
}>()
</script>
<style lang="scss">
.artist-top-tracks {
padding-top: 1rem;
padding-top: 1rem;
.section-title {
margin-left: 0;
align-items: baseline;
}
.error {
padding-left: 1rem;
color: $red;
}
h3 {
display: flex;
justify-content: space-between;
padding-left: 1rem !important; // applies to favorite page
padding-right: $small;
@include largePhones {
padding-left: $small !important;
.section-title {
margin-left: 0;
align-items: baseline;
}
.section-title.isSmall {
padding-left: 0.5rem !important;
}
.error {
padding-left: 1rem;
color: $red;
}
h3 {
display: flex;
justify-content: space-between;
padding-left: 1rem !important; // applies to favorite page
padding-right: $small;
@media only screen and (max-width: 724px) {
padding-left: $small !important; // applies to favorite page
}
}
}
}
</style>

View File

@@ -1,219 +1,218 @@
<template>
<div
class="b-bar"
:style="{
paddingLeft: `${settings.is_default_layout ? '1rem' : ''}`,
paddingRight: `${settings.is_default_layout ? '1rem' : ''}`,
}"
>
<LeftGroup @handleFav="handleFav" />
<div class="center">
<div v-if="!isMobile" class="with-time">
<div class="time time-current">
<div class="numbers">
{{ formatSeconds(queue.duration.current || 0) }}
</div>
</div>
<div
class="b-bar"
:style="{
paddingLeft: `${settings.is_default_layout ? '1rem' : ''}`,
paddingRight: `${settings.is_default_layout ? '1rem' : ''}`,
}"
>
<LeftGroup @handleFav="handleFav" />
<div class="center">
<div v-if="!isMobile" class="with-time">
<div class="time time-current">
<div class="numbers">
{{ formatSeconds(queue.duration.current || 0) }}
</div>
</div>
<div class="buttons rounded-sm border">
<HotKeys />
<div class="buttons rounded-sm border">
<HotKeys />
</div>
<div class="time time-full">
<div class="numbers">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<Progress />
</div>
<div class="time time-full">
<div class="numbers">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<Progress />
<RightGroup v-if="!isMobile" @handleFav="handleFav" />
<Navigation v-else />
</div>
<RightGroup v-if="!isMobile" @handleFav="handleFav" />
<Navigation v-else />
</div>
</template>
<script setup lang="ts">
import { favType } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { isMobile } from "@/stores/content-width";
import { formatSeconds } from "@/utils";
import { favType } from '@/enums'
import favoriteHandler from '@/helpers/favoriteHandler'
import { isMobile } from '@/stores/content-width'
import { formatSeconds } from '@/utils'
import useQStore from "@/stores/queue";
import useSettings from "@/stores/settings";
import useQStore from '@/stores/queue'
import useSettings from '@/stores/settings'
import HotKeys from "@/components/LeftSidebar/NP/HotKeys.vue";
import Progress from "@/components/LeftSidebar/NP/Progress.vue";
import Navigation from "@/components/LeftSidebar/NavButtons.vue";
import HotKeys from '@/components/LeftSidebar/NP/HotKeys.vue'
import Progress from '@/components/LeftSidebar/NP/Progress.vue'
import Navigation from '@/components/LeftSidebar/NavButtons.vue'
import LeftGroup from "./Left.vue";
import RightGroup from "./Right.vue";
import LeftGroup from './Left.vue'
import RightGroup from './Right.vue'
const queue = useQStore();
const settings = useSettings();
const queue = useQStore()
const settings = useSettings()
function handleFav() {
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || "",
() => null,
() => null
);
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || '',
() => null,
() => null
)
}
</script>
<style lang="scss">
.b-bar {
background-color: rgb(22, 22, 22);
display: grid;
grid-template-columns: 1fr max-content 1fr;
align-items: center;
z-index: 1;
@include allPhones {
display: flex;
flex-direction: column;
align-items: unset;
gap: $small;
padding: $medium 1rem;
/* Hiding the dot/thumb/handle for readonly input */
/* Webkit browsers, Firefox, IE etc */
&:hover > .center > #progress::-webkit-slider-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-moz-range-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-ms-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
}
button {
background: transparent;
border-radius: $small;
width: 3rem;
transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
background-color: rgb(22, 22, 22);
display: grid;
grid-template-columns: 1fr max-content 1fr;
align-items: center;
z-index: 1;
@include allPhones {
height: 3rem;
display: flex;
flex-direction: column;
align-items: unset;
gap: $small;
padding: $medium 1rem;
/* Hiding the dot/thumb/handle for readonly input */
/* Webkit browsers, Firefox, IE etc */
&:hover > .center > #progress::-webkit-slider-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-moz-range-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
&:hover > .center > #progress::-ms-thumb {
display: none;
opacity: 0;
visibility: hidden;
}
}
@include largePhones {
width: 2.5rem;
height: 2.5rem;
&:nth-child(2) {
width: 3.5rem;
}
}
@include smallestPhones {
&:first-child {
display: none;
}
&:nth-child(2) {
margin-left: $smaller;
}
&:last-child {
display: none;
}
}
}
&:hover {
// INFO: Show the progress bar when hovering over the bottom bar
#progress::-moz-range-thumb {
height: 1rem;
width: 1rem;
}
#progress::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
}
#progress::-ms-thumb {
height: 1rem;
width: 1rem;
}
// INFO: Also show the expand button
.np-image .expandicon {
opacity: 1;
}
}
.with-time {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: flex-end;
height: 2rem;
button {
background: transparent;
}
}
background: transparent;
border-radius: $small;
width: 3rem;
transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
.center {
display: grid;
align-items: center;
gap: $small;
margin-bottom: -$smallest;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
width: 30rem;
@include allPhones {
height: 3rem;
}
@media only screen and (max-width: 1080px) {
width: 20rem !important;
@include largePhones {
width: 2.5rem;
height: 2.5rem;
&:nth-child(2) {
width: 3.5rem;
}
}
@include smallestPhones {
&:first-child {
display: none;
}
&:nth-child(2) {
margin-left: $smaller;
}
&:last-child {
display: none;
}
}
}
@include allPhones {
width: 100% !important;
margin: 4px -16px;
user-select: none;
pointer-events: none;
&:hover {
// INFO: Show the progress bar when hovering over the bottom bar
#progress::-moz-range-thumb {
height: 1rem;
width: 1rem;
}
> #progress {
height: 1px !important;
width: 100vw !important;
margin: unset;
}
#progress::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
}
#progress::-ms-thumb {
height: 1rem;
width: 1rem;
}
// INFO: Also show the expand button
.np-image .expandicon {
opacity: 1;
}
}
.time {
font-weight: 500;
font-size: $medium;
.with-time {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: flex-end;
height: 2rem;
.numbers {
background-color: $gray3;
border-radius: $smaller;
padding: 1px $smaller;
font-variant-numeric: tabular-nums;
}
button {
background: transparent;
}
}
}
// hotkey
.buttons {
display: grid;
place-items: center;
transform: scale(1.2);
border: none;
}
.center {
display: grid;
align-items: center;
gap: 0.625rem;
width: 30rem;
@media only screen and (max-width: 1080px) {
width: 20rem !important;
}
@include allPhones {
width: 100% !important;
margin: 4px -16px;
user-select: none;
pointer-events: none;
> #progress {
height: 1px !important;
width: 100vw !important;
margin: unset;
}
}
.time {
font-weight: 500;
font-size: $medium;
.numbers {
background-color: $gray3;
border-radius: $smaller;
padding: 1px $smaller;
font-variant-numeric: tabular-nums;
}
}
}
// hotkey
.buttons {
display: grid;
place-items: center;
transform: scale(1.2);
border: none;
}
}
</style>

View File

@@ -1,178 +1,187 @@
<template>
<div v-auto-animate class="left-group">
<HeartSvg
v-if="settings.use_np_img && !isMobile"
:state="queue.currenttrack?.is_favorite"
@handleFav="$emit('handleFav')"
/>
<RouterLink
v-else
title="Go to Now Playing"
:to="{
name: Routes.nowPlaying,
params: {
tab: 'home',
},
replace: true,
}"
class="np-image rounded-sm no-scroll"
>
<img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" />
<div class="expandicon">
<ExpandSvg />
</div>
</RouterLink>
<div
class="track-info"
:style="{
color: getShift(colors.theme1, [0, -170]),
}"
>
<div v-tooltip class="title">
<span class="ellip">
{{ queue.currenttrack?.title || "Hello there" }}
</span>
<MasterFlag :bitrate="queue.currenttrack?.bitrate || 0" />
</div>
<ArtistName
:artists="queue.currenttrack?.artists || []"
:albumartists="queue.currenttrack?.albumartists || 'Welcome to Swing Music'"
class="artist"
/>
<div v-auto-animate class="left-group">
<HeartSvg
v-if="settings.use_np_img && !isMobile"
:state="queue.currenttrack?.is_favorite"
@handleFav="$emit('handleFav')"
/>
<RouterLink
v-else
title="Go to Now Playing"
:to="{
name: Routes.nowPlaying,
params: {
tab: 'home',
},
replace: true,
}"
class="np-image rounded-sm no-scroll"
>
<img :src="paths.images.thumb.small + queue.currenttrack?.image" alt="" />
<div class="expandicon">
<ExpandSvg />
</div>
</RouterLink>
<div
class="track-info"
:style="{
color: getShift(colors.theme1, [0, -170]),
}"
>
<div v-tooltip class="title">
<span class="ellip">
{{ queue.currenttrack?.title || 'Hello there' }}
</span>
<ExplicitIcon class="explicit-icon" v-if="queue.currenttrack?.explicit" />
<MasterFlag :bitrate="queue.currenttrack?.bitrate || 0" />
</div>
<ArtistName
:artists="queue.currenttrack?.artists || []"
:albumartists="queue.currenttrack?.albumartists || 'Welcome to Swing Music'"
class="artist"
/>
</div>
<Actions v-if="isLargerMobile" @handleFav="$emit('handleFav')" />
<HotKeys v-if="isMobile" />
</div>
<Actions v-if="isLargerMobile" @handleFav="$emit('handleFav')" />
<HotKeys v-if="isMobile" />
</div>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { Routes } from "@/router";
import { getShift } from "@/utils/colortools/shift";
import { paths } from '@/config'
import { Routes } from '@/router'
import { getShift } from '@/utils/colortools/shift'
import useColorStore from "@/stores/colors";
import { isLargerMobile, isMobile } from "@/stores/content-width";
import useQStore from "@/stores/queue";
import useSettingsStore from "@/stores/settings";
import useColorStore from '@/stores/colors'
import { isLargerMobile, isMobile } from '@/stores/content-width'
import useQStore from '@/stores/queue'
import useSettingsStore from '@/stores/settings'
import ExpandSvg from "@/assets/icons/expand.svg";
import ArtistName from "@/components/shared/ArtistName.vue";
import HotKeys from "../LeftSidebar/NP/HotKeys.vue";
import HeartSvg from "../shared/HeartSvg.vue";
import MasterFlag from "../shared/MasterFlag.vue";
import Actions from "./Right.vue";
import ExpandSvg from '@/assets/icons/expand.svg'
import ArtistName from '@/components/shared/ArtistName.vue'
import HotKeys from '../LeftSidebar/NP/HotKeys.vue'
import HeartSvg from '../shared/HeartSvg.vue'
import MasterFlag from '../shared/MasterFlag.vue'
import Actions from './Right.vue'
import ExplicitIcon from '@/assets/icons/explicit.svg'
const queue = useQStore();
const settings = useSettingsStore();
const colors = useColorStore();
const queue = useQStore()
const settings = useSettingsStore()
const colors = useColorStore()
defineEmits<{
(e: "handleFav"): void;
}>();
(e: 'handleFav'): void
}>()
</script>
<style lang="scss">
.left-group {
display: grid;
grid-template-columns: max-content 1fr;
gap: $medium;
align-items: center;
font-size: small;
font-weight: 700;
line-height: 1.2;
margin-right: $medium;
display: grid;
grid-template-columns: max-content 1fr;
gap: $medium;
align-items: center;
font-size: small;
font-weight: 700;
line-height: 1.2;
margin-right: $medium;
.np-image {
position: relative;
height: 3rem;
.np-image {
position: relative;
height: 3rem;
img {
height: 100%;
img {
height: 100%;
}
.expandicon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(51, 51, 51, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease-out, height 0.2s ease-out, transform 0.2s ease-out,
background-color 0.2s ease-out;
svg {
transform: rotate(-90deg) scale(0.92);
}
}
&:hover {
.expandicon {
transform: translateY(-$medium);
height: 130%;
}
}
&:active {
.expandicon {
background-color: rgba(51, 51, 51, 0.74);
}
}
@include largePhones {
flex-shrink: 0;
margin-right: $medium;
}
@include smallerPhones {
margin-right: $small;
}
}
.expandicon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(51, 51, 51, 0.575);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s;
svg {
transform: rotate(-90deg);
}
.heart-button {
height: 3rem;
width: 3rem;
border: solid 1px $gray4;
padding: 0;
}
&:hover {
.expandicon {
transform: translateY(-$medium);
height: 130%;
}
}
.track-info {
.title {
color: $white;
display: flex;
align-items: center;
margin-bottom: 2px;
}
@include largePhones {
flex-shrink: 0;
margin-right: $medium;
}
.artistname {
opacity: 0.75;
@include smallerPhones {
margin-right: $small;
}
}
a {
font-size: 0.8rem;
}
}
.heart-button {
height: 3rem;
width: 3rem;
border: solid 1px $gray4;
padding: 0;
}
@include allPhones {
width: calc(100% + 8px);
}
.track-info {
.title {
color: $white;
display: flex;
align-items: center;
margin-bottom: 2px;
}
.artistname {
opacity: 0.75;
a {
font-size: 0.8rem;
}
@include largePhones {
width: unset;
flex-grow: 1;
}
}
@include allPhones {
width: calc(100% + 8px);
grid-template-columns: max-content 1fr max-content max-content;
margin-right: unset;
.heart-button {
height: max-content;
border: 1px solid transparent;
}
}
@include largePhones {
width: unset;
flex-grow: 1;
display: flex;
gap: 0;
max-width: calc(100% - 8px);
}
}
@include allPhones {
grid-template-columns: max-content 1fr max-content max-content;
margin-right: unset;
.heart-button {
height: max-content;
border: 1px solid transparent;
}
}
@include largePhones {
display: flex;
gap: 0;
max-width: calc(100% - 8px);
}
}
</style>

View File

@@ -1,91 +1,95 @@
<template>
<div class="right-group">
<LyricsButton />
<Volume />
<button
class="repeat"
:class="{ 'repeat-disabled': settings.no_repeat }"
:title="settings.repeat_all ? 'Repeat all' : settings.no_repeat ? 'No repeat' : 'Repeat one'"
@click="settings.toggleRepeatMode"
>
<RepeatOneSvg v-if="settings.repeat_one" />
<RepeatAllSvg v-else />
</button>
<button title="Shuffle" @click="queue.shuffleQueue">
<ShuffleSvg />
</button>
<HeartSvg
v-if="!hideHeart"
title="Favorite"
:state="queue.currenttrack?.is_favorite"
@handleFav="() => $emit('handleFav')"
/>
</div>
<div class="right-group">
<LyricsButton />
<Volume />
<button
class="repeat"
:class="{ 'repeat-disabled': settings.no_repeat }"
:title="settings.repeat_all ? 'Repeat all' : settings.no_repeat ? 'No repeat' : 'Repeat one'"
@click="settings.toggleRepeatMode"
>
<RepeatOneSvg v-if="settings.repeat_one" />
<RepeatAllSvg v-else />
</button>
<button title="Shuffle" @click="queue.shuffleQueue">
<ShuffleSvg />
</button>
<HeartSvg
v-if="!hideHeart"
title="Favorite"
:state="queue.currenttrack?.is_favorite"
@handleFav="() => $emit('handleFav')"
/>
</div>
</template>
<script setup lang="ts">
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import RepeatOneSvg from "@/assets/icons/repeat-one.svg";
import RepeatAllSvg from "@/assets/icons/repeat.svg";
import ShuffleSvg from "@/assets/icons/shuffle.svg";
import HeartSvg from "../shared/HeartSvg.vue";
import LyricsButton from "../shared/LyricsButton.vue";
import Volume from "./Volume.vue";
import RepeatOneSvg from '@/assets/icons/repeat-one.svg'
import RepeatAllSvg from '@/assets/icons/repeat.svg'
import ShuffleSvg from '@/assets/icons/shuffle.svg'
import HeartSvg from '../shared/HeartSvg.vue'
import LyricsButton from '../shared/LyricsButton.vue'
import Volume from './Volume.vue'
const queue = useQueue();
const settings = useSettings();
const queue = useQueue()
const settings = useSettings()
defineProps<{
hideHeart?: boolean;
}>();
hideHeart?: boolean
}>()
defineEmits<{
(event: "handleFav"): void;
}>();
(event: 'handleFav'): void
}>()
</script>
<style lang="scss">
.right-group {
display: grid;
justify-content: flex-end;
grid-template-columns: repeat(5, max-content);
align-items: center;
height: 4rem;
display: grid;
justify-content: flex-end;
grid-template-columns: repeat(5, max-content);
align-items: center;
height: 4rem;
@include allPhones {
width: max-content;
height: unset;
}
button {
height: 3rem !important;
width: 3rem !important;
background-color: transparent;
border: solid 1px transparent;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
@include allPhones {
width: max-content;
height: unset;
}
}
.lyrics,
.repeat {
svg {
transform: scale(0.75);
button {
height: 3rem !important;
width: 3rem !important;
background-color: transparent;
border: solid 1px transparent;
&:hover {
border: solid 1px $gray3 !important;
background-color: $gray !important;
}
}
}
button.repeat.repeat-disabled {
svg {
opacity: 0.25;
.lyrics,
.repeat {
svg {
transform: scale(0.75);
}
&:active > svg {
transform: scale(0.6);
}
}
}
.heart-button {
border: solid 1px $gray4 !important;
}
button.repeat.repeat-disabled {
svg {
opacity: 0.25;
}
}
.heart-button {
border: solid 1px $gray4 !important;
}
}
</style>

View File

@@ -1,134 +1,135 @@
<template>
<button class="speaker" @wheel.passive="handleMouseWheel">
<div class="icon" @click="settings.toggleMute">
<VolumeMuteSvg v-if="settings.mute || settings.volume == 0.0" />
<VolumeMidSvg v-else-if="settings.volume > 0.75" />
<VolumeLowSvg v-else-if="settings.volume > 0" />
</div>
<div class="dialog rounded-sm pad-sm">
<input
id="volume"
type="range"
name="volume"
max="1"
min="0"
step="0.01"
:value="settings.volume"
@input="changeVolume"
:style="{
backgroundSize: `${(settings.volume / 1) * 100}% 100%`,
}"
/>
<div className="volume_indicator">{{ ((settings.volume / 1) * 100).toFixed(0) }}</div>
</div>
</button>
<button class="speaker" @wheel.passive="handleMouseWheel">
<div class="icon" @click="settings.toggleMute">
<VolumeMuteSvg v-if="settings.mute || settings.volume == 0.0" />
<VolumeMidSvg v-else-if="settings.volume > 0.75" />
<VolumeLowSvg v-else-if="settings.volume > 0" />
</div>
<div class="dialog rounded-sm pad-sm">
<input
id="volume"
type="range"
name="volume"
max="1"
min="0"
step="0.01"
:value="settings.volume"
@input="changeVolume"
:style="{
backgroundSize: `${(settings.volume / 1) * 100}% 100%`,
}"
/>
<div className="volume_indicator">{{ ((settings.volume / 1) * 100).toFixed(0) }}</div>
</div>
</button>
</template>
<script setup lang="ts">
import VolumeLowSvg from "@/assets/icons/volume-low.svg";
import VolumeMidSvg from "@/assets/icons/volume-mid.svg";
import VolumeMuteSvg from "@/assets/icons/volume-mute.svg";
import useSettingsStore from "@/stores/settings";
import VolumeLowSvg from '@/assets/icons/volume-low.svg'
import VolumeMidSvg from '@/assets/icons/volume-mid.svg'
import VolumeMuteSvg from '@/assets/icons/volume-mute.svg'
import useSettingsStore from '@/stores/settings'
const settings = useSettingsStore();
const settings = useSettingsStore()
const changeVolume = (event: Event) => {
const target = event.target as HTMLInputElement;
settings.setVolume(parseFloat(target.value));
};
const target = event.target as HTMLInputElement
settings.setVolume(parseFloat(target.value))
}
const handleMouseWheel = (event: WheelEvent) => {
const delta = event.deltaY / 1000;
let newVolume = settings.volume - delta / 3;
const delta = event.deltaY / 1000
let newVolume = settings.volume - delta / 3
if (newVolume > 1) {
newVolume = 1;
}
if (newVolume > 1) {
newVolume = 1
}
if (newVolume < 0) {
newVolume = 0;
}
if (newVolume < 0) {
newVolume = 0
}
settings.setVolume(newVolume);
};
settings.setVolume(newVolume)
}
</script>
<style lang="scss">
.b-bar .right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.speaker {
position: relative;
position: relative;
.icon {
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
svg {
transform: scale(0.75);
}
.dialog {
position: absolute;
cursor: default;
bottom: 56px;
left: -1px;
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 4px;
background-color: $gray;
border-top: 1px solid $gray3;
border-bottom: 1px solid $gray3;
border-right: 1px solid $gray3;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
-webkit-font-smoothing: antialiased;
transform: rotate(270deg) translateX(-50%) perspective(1px);
transform-origin: left top;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-out, visibility 0.2s ease-out;
input {
width: max-content;
max-width: 87px;
margin: 0;
touch-action: pan-x;
&::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
&::-moz-range-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
.icon {
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
svg {
transform: scale(0.75);
}
}
&:hover {
.dialog {
opacity: 1;
visibility: visible;
}
}
position: absolute;
cursor: default;
bottom: 56px;
left: -1px;
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 4px;
background-color: $gray;
border-top: 1px solid $gray3;
border-bottom: 1px solid $gray3;
border-right: 1px solid $gray3;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
-webkit-font-smoothing: antialiased;
transform: rotate(270deg) translateX(-50%) perspective(1px);
transform-origin: left top;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-out, visibility 0.2s ease-out;
.volume_indicator {
font-weight: 600;
width: 24px;
height: 18px;
transform: rotate(90deg) translate3d(0, 0, 0);
}
input {
width: max-content;
max-width: 87px;
margin: 0;
touch-action: pan-x;
background: linear-gradient(to top, #ffffff, #ffffff) 0% 50% no-repeat, $gray4;
&::-webkit-slider-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
&::-moz-range-thumb {
height: 1rem;
width: 1rem;
cursor: pointer;
}
}
}
&:hover {
.dialog {
opacity: 1;
visibility: visible;
}
}
.volume_indicator {
font-weight: 600;
width: 24px;
height: 18px;
transform: rotate(90deg) translate3d(0, 0, 0);
}
}
</style>

View File

@@ -7,21 +7,10 @@
@mouseleave="handleMouseLeave"
@click="runAction"
>
<div
class="icon image"
v-html="option.icon"
></div>
<div class="icon image" v-html="option.icon"></div>
<div class="label ellip">{{ option.label }}</div>
<div
v-if="hasChildren && !option.singleChild"
class="more"
v-html="ExpandIcon"
></div>
<div
v-if="children"
ref="childRef"
class="children rounded shadow-sm"
>
<div v-if="hasChildren && !option.singleChild" class="more" v-html="ExpandIcon"></div>
<div v-if="children" ref="childRef" class="children rounded shadow-sm">
<div className="wrapper">
<div
v-for="child in children"
@@ -40,13 +29,7 @@
</template>
<script setup lang="ts">
import {
createPopper,
Instance,
Modifier,
Placement,
Rect,
} from '@popperjs/core'
import { createPopper, Instance, Modifier, Placement, Rect } from '@popperjs/core'
import { computed, ref } from 'vue'
import { contextChildrenShowMode } from '@/enums'
@@ -72,10 +55,7 @@ const childRef = ref<HTMLElement>()
const parentRef = ref<HTMLElement>()
const hasChildren = computed(() => {
return (
props.option.children &&
props.childrenShowMode === contextChildrenShowMode.hover
)
return props.option.children && props.childrenShowMode === contextChildrenShowMode.hover
})
let popperInstance: Instance | null = null
@@ -84,7 +64,7 @@ async function handleMouseEnter() {
if (!hasChildren.value) return
stillWaitingForChildren.value = true
await new Promise((resolve) => setTimeout(resolve, showChildrenDelay))
await new Promise(resolve => setTimeout(resolve, showChildrenDelay))
if (stillWaitingForChildren.value) {
showChildren()
@@ -122,11 +102,7 @@ async function showChildren() {
{
offset:
| [number, number]
| ((args: {
placement: Placement
reference: Rect
popper: Rect
}) => [number, number])
| ((args: { placement: Placement; reference: Rect; popper: Rect }) => [number, number])
}
> = {
name: 'offset',
@@ -141,30 +117,26 @@ async function showChildren() {
},
}
popperInstance = createPopper(
parentRef.value as HTMLElement,
childRef.value as HTMLElement,
{
placement: 'right-start',
modifiers: [
{
name: 'preventOverflow',
options: {
altAxis: true,
boundariesElement: 'viewport',
},
popperInstance = createPopper(parentRef.value as HTMLElement, childRef.value as HTMLElement, {
placement: 'right-start',
modifiers: [
{
name: 'preventOverflow',
options: {
altAxis: true,
boundariesElement: 'viewport',
},
{
name: 'flip',
options: {
fallbackPlacements: ['left-start', 'auto'],
boundariesElement: 'viewport',
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['left-start', 'auto'],
boundariesElement: 'viewport',
},
offsetModifier,
],
}
)
},
offsetModifier,
],
})
childRef.value ? (childRef.value.style.visibility = 'visible') : null
childRef.value ? (childRef.value.style.opacity = '1') : null
childrenShown.value = true
@@ -204,6 +176,7 @@ function runChildAction(action: () => void) {
<style lang="scss">
.context-item {
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
@@ -217,7 +190,7 @@ function runChildAction(action: () => void) {
width: 1.5rem;
position: absolute;
right: 2px;
bottom: 5px;
bottom: 6px;
transform: scale(0.65);
}
@@ -288,4 +261,9 @@ function runChildAction(action: () => void) {
width: 9rem;
}
}
/* Removes the cursor pointer on the empty area within children dropdown of context-items */
.context-item:has(.children) > .children {
cursor: initial !important;
}
</style>

View File

@@ -83,22 +83,20 @@ const browselist = [
icon: AlbumIcon,
class: "favorite",
},
// {
// title: "Settings",
// route: null,
// icon: SettingsIcon,
// action: () => {
// useDialog().showSettingsModal();
// },
// class: "settings",
// },
{
title: "Settings",
route: null,
icon: SettingsIcon,
action: () => {
useDialog().showSettingsModal();
},
class: "settings",
},
{
title: "Quick scan",
route: null,
icon: ReloadIcon,
action: triggerScan,
class: "reload",
},
title: "Stats",
icon: AlbumIcon,
route: Routes.Stats,
}
];
</script>

View File

@@ -0,0 +1,63 @@
<template>
<RouterLink
:to="{
name: Routes.Mix,
params: {
mixid: mix.id,
},
query: mix.extra.type === 'artist' ? { src: mix.sourcehash } : { src: mix.extra.og_sourcehash },
}"
class="mixcard rounded"
>
<MixImage :mix="mix" :on_header="on_header" />
<div class="info">
<div class="mix rhelp" v-if="mix.time || mix.help_text">
<span class="help" v-if="mix.help_text">{{ mix.extra.type }} {{ mix.help_text }} </span>
<span class="time"> {{ mix.time }} </span>
</div>
<div class="description ellip2">
{{ mix.description }}
</div>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { Mix } from '@/interfaces'
import { RouterLink } from 'vue-router'
import { Routes } from '@/router'
import MixImage from './MixImage.vue'
defineProps<{
mix: Mix
on_header?: boolean
}>()
</script>
<style lang="scss">
.mixcard {
padding: $medium;
&:hover {
background-color: $gray;
cursor: pointer;
}
.info {
margin-top: $small;
.title {
font-size: 1rem;
font-weight: 600;
}
.description {
font-size: 0.8rem;
font-weight: 500;
color: $gray1;
margin-top: $smaller;
}
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="miximage" :class="{ on_header }">
<div
class="infooverlay"
v-if="!mix.extra['image']"
:style="{
color: getTextColor(mix.extra.images?.[0]?.color || ''),
}"
>
<div class="type" :style="{ color: getTypeColor(mix.extra.images?.[0]?.color || '') }">
{{ mix.extra['type'] }} mix
</div>
<div class="title ellip">{{ mix.title.replace('Radio', '') }}</div>
</div>
<img
class="main"
:src="getImageUrl(mix.extra['image']?.image || '', false)"
v-if="mix.extra['image']"
:key="mix.extra['image']['image']"
/>
<div class="images" v-else>
<img
v-for="image in mix.extra['images']"
class="shadow-sm"
:src="getImageUrl(image, true)"
:key="image['image']"
/>
</div>
<div
class="gradient rounded-sm"
v-if="!mix.extra['image']"
:style="{
background: gradient,
}"
></div>
</div>
</template>
<script setup lang="ts">
import { paths } from '@/config'
import { Mix } from '@/interfaces'
import { addOpacity } from '@/utils/colortools/shift'
import { getTextColor } from '@/utils/colortools/shift'
import { getTypeColor } from '@/utils/colortools'
import { onMounted, ref } from 'vue'
const props = defineProps<{
mix: Mix
on_header?: boolean
}>()
const gradient = ref('')
async function getGradient() {
let color = props.mix.extra.image?.color
if (!color) {
color = props.mix.extra.images?.[0]?.color
}
if (color) {
return `linear-gradient(27deg, ${color} 21%, ${addOpacity(
color,
0.15
)}),linear-gradient(-17deg, ${color} 10%, ${addOpacity(color, 0)} 30%)`
}
return ''
}
function getImageUrl(image: any, is_extra: boolean = false) {
if (is_extra) {
if (image['type'] == 'artist') {
return paths.images.artist.medium + image['image']
}
return paths.images.thumb.medium + image['image']
}
if (props.on_header) {
return paths.images.mix.medium + image
}
return paths.images.mix.medium + image
}
onMounted(async () => {
gradient.value = await getGradient()
})
</script>
<style lang="scss">
.miximage {
position: relative;
aspect-ratio: 1;
.gradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5));
}
.infooverlay {
position: absolute;
bottom: $small;
z-index: 1;
left: $small;
.type {
font-size: 0.9rem;
font-weight: 900;
text-transform: capitalize;
// color: rgb(109, 69, 16) !important;
}
.title {
font-size: 1.15rem;
font-weight: 900;
}
}
.main {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.59rem;
}
.images {
border-radius: 0.59rem;
overflow: hidden;
height: 100%;
width: 100%;
position: relative;
img {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0;
height: 50%;
object-fit: cover;
border-radius: 0 !important;
}
img:nth-child(2) {
left: 25%;
}
img:nth-child(3) {
left: 50%;
}
}
}
.miximage.on_header {
height: 100%;
img {
border-radius: 1.1rem;
}
.gradient {
border-radius: 1rem;
}
.infooverlay {
padding: $small;
.type {
font-size: 1.25rem;
font-weight: 900;
}
.title {
font-size: 2rem;
font-weight: 900;
}
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="mixheader" v-if="mix.title">
<MixImage :mix="mix" :on_header="true" />
<div class="mixinfo">
<div class="header_type">{{ mix.extra['type'] }} mix</div>
<div class="header_title">{{ mix.title }}</div>
<div class="header_description ellip2">
{{ mix.description }}
</div>
<div class="bunchofstuff">
{{ mix.trackcount }} track{{ mix.trackcount === 1 ? '' : 's' }} â–¸ {{ mix.duration }}
</div>
<div class="buttons">
<PlayBtnRect :source="playSources.mix" :bg_color="'#fff'" @click.prevent="$emit('playThis')" />
<button class="savebtn" :title="mix.saved ? 'Saved Mix' : 'Save Mix'" @click="saveMix">
<SaveFilledSvg v-if="mix.saved" />
<SaveSvg v-else />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FullMix } from '@/interfaces'
import MixImage from './MixImage.vue'
import PlayBtnRect from '../shared/PlayBtnRect.vue'
import SaveSvg from '@/assets/icons/bookmark.svg'
import SaveFilledSvg from '@/assets/icons/bookmark.fill.svg'
import { playSources } from '@/enums'
import useAxios from '@/requests/useAxios'
import { paths } from '@/config'
const props = defineProps<{
mix: FullMix
}>()
defineEmits<{
(e: 'playThis'): void
}>()
async function saveMix() {
const initialState = props.mix.saved
props.mix.saved = !initialState
const res = await useAxios({
url: paths.api.mixes + '/save',
method: 'POST',
props: {
type: props.mix.extra.type,
mixid: props.mix.id,
// INFO: save artist mixes using their sourcehash,
// but track mixes using their og_sourcehash, as track mixes are based
// on artist mixes
sourcehash: props.mix.extra.type === 'artist' ? props.mix.sourcehash : props.mix.extra.og_sourcehash,
},
})
if (res.status !== 200) {
props.mix.saved = initialState
}
}
</script>
<style lang="scss">
.mixheader {
height: 18rem;
display: grid;
grid-template-columns: 17.5rem 1fr;
gap: 1rem;
padding: $small;
.mixinfo {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.header_type {
font-weight: 600;
text-transform: capitalize;
font-size: 14px;
color: $gray1;
}
.header_title {
font-size: 4rem;
font-weight: 900;
}
.header_description {
font-size: 1rem;
font-weight: 500;
margin-top: $smaller;
color: $brown;
}
.bunchofstuff {
margin-top: $small;
font-size: 14px;
font-weight: 500;
}
.buttons {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
.savebtn {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
svg {
height: 1.5rem;
}
}
}
}
</style>

View File

@@ -1,18 +1,7 @@
<template>
<div
v-if="notifStore.notifs"
class="toasts"
>
<div
v-for="notif in notifStore.notifs"
:key="notif.text"
class="new-notif rounded-sm"
:class="notif.type"
>
<component
:is="getSvg(notif.type)"
class="notif-icon"
/>
<div v-if="notifStore.notifs" class="toasts">
<div v-for="notif in notifStore.notifs" :key="notif.text" class="new-notif rounded-sm" :class="notif.type">
<component :is="getSvg(notif.type)" class="notif-icon" />
<div class="notif-text">{{ notif.text }}</div>
</div>
</div>
@@ -46,46 +35,71 @@ function getSvg(notif: NotifType) {
</script>
<style lang="scss">
.toasts {
position: fixed;
bottom: 6rem;
left: 50%;
width: 100%;
transform: translate(-50%);
z-index: 1003;
display: flex;
align-items: center;
flex-direction: column-reverse;
gap: 1rem;
position: fixed;
bottom: 6rem;
left: 50%;
width: 100%;
transform: translate(-50%);
z-index: 1003;
display: flex;
align-items: center;
flex-direction: column-reverse;
gap: 1rem;
}
.new-notif {
font-size: 0.85rem;
font-weight: 600;
width: 18rem;
min-height: 4rem;
background-color: $gray;
display: grid;
place-items: center;
box-shadow: 0px 0px 2rem rgba(0, 0, 0, 0.466);
padding: 1rem $small;
position: relative;
font-size: 0.85rem;
font-weight: 600;
color: $white;
display: grid;
place-items: center;
width: 100%;
max-width: 18rem;
min-height: 4rem;
padding: 1rem $medium;
padding-right: $large;
border: 1px solid $gray5;
background-color: $gray;
box-shadow: 0px 0px 2rem rgba(0, 0, 0, 0.6);
grid-template-columns: 2rem 3fr;
gap: $smaller;
gap: $small;
.notif-text {
width: 100%;
}
.notif-text {
width: 100%;
}
@include smallestPhones {
max-width: calc(100% - 2rem);
}
@include smallestPhones {
max-width: calc(100% - 2rem);
}
}
.new-notif.error > .notif-icon {
color: #c54848;
}
.new-notif.info > .notif-icon {
color: #418dc0;
}
.new-notif.favorite > .notif-icon,
.new-notif.success > .notif-icon {
color: #4cbd4c;
}
.new-notif.working > .notif-icon {
color: $white;
}
/*
.new-notif.error {
$bg: rgb(197, 72, 72);
background-color: $bg;
}
*/
/*
.new-notif.info,
.new-notif.favorite,
.new-notif.success {
@@ -93,9 +107,12 @@ function getSvg(notif: NotifType) {
background-color: $bg;
color: $black;
}
*/
/*
.new-notif.working {
$bg: $gray4;
background-color: $bg;
}
*/
</style>

View File

@@ -1,176 +1,186 @@
<template>
<div class="now-playing-header">
<div class="centered">
<PlayingFrom />
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack?.albumhash || ' ',
},
}"
title="Go to Album"
class="np-image"
>
<img v-motion-fade class="rounded" :src="paths.images.thumb.large + queue.currenttrack?.image" />
</RouterLink>
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isSmallPhone" />
<div v-if="isSmallPhone" class="below-progress">
<div class="time">
{{ formatSeconds(queue.duration.current) }}
<div class="now-playing-header">
<div class="centered">
<PlayingFrom />
<RouterLink
:to="{
name: Routes.album,
params: {
albumhash: queue.currenttrack?.albumhash || ' ',
},
}"
title="Go to Album"
class="np-image"
>
<img v-motion-fade class="rounded" :src="paths.images.thumb.large + queue.currenttrack?.image" />
</RouterLink>
<NowPlayingInfo @handle-fav="handleFav" />
<Progress v-if="isMobile" />
<div class="below-progress">
<div v-if="isMobile" class="time">
{{ formatSeconds(queue.duration.current) }}
</div>
<Buttons v-if="isSmallPhone" :hide-heart="true" @handleFav="() => {}" />
<div v-if="isMobile" class="time">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
</div>
<Buttons :hide-heart="true" @handleFav="() => {}" />
<div class="time">
{{ formatSeconds(queue.duration.full) }}
</div>
</div>
<h3 class="nowplaying_title" v-if="queue.next">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
:index="queue.nextindex + 1"
:source="dropSources.folder"
@play-this="queue.playNext"
/>
<h3 class="nowplaying_title">Queue</h3>
</div>
<h3 class="nowplaying_title" v-if="queue.next">Up Next</h3>
<SongItem
v-if="queue.next"
:track="queue.next"
:index="queue.nextindex + 1"
:source="dropSources.folder"
@play-this="queue.playNext"
/>
<h3 class="nowplaying_title">Queue</h3>
</div>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { dropSources, favType } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Routes } from "@/router";
import { isSmallPhone } from "@/stores/content-width";
import useQueueStore from "@/stores/queue";
import { formatSeconds } from "@/utils";
import { paths } from '@/config'
import { dropSources, favType } from '@/enums'
import favoriteHandler from '@/helpers/favoriteHandler'
import { Routes } from '@/router'
import { isMobile, isSmallPhone } from '@/stores/content-width'
import useQueueStore from '@/stores/queue'
import { formatSeconds } from '@/utils'
import Progress from "@/components/LeftSidebar/NP/Progress.vue";
import Buttons from "../BottomBar/Right.vue";
import SongItem from "../shared/SongItem.vue";
import NowPlayingInfo from "./NowPlayingInfo.vue";
import PlayingFrom from "./PlayingFrom.vue";
import Progress from '@/components/LeftSidebar/NP/Progress.vue'
import Buttons from '../BottomBar/Right.vue'
import SongItem from '../shared/SongItem.vue'
import NowPlayingInfo from './NowPlayingInfo.vue'
import PlayingFrom from './PlayingFrom.vue'
const queue = useQueueStore();
const queue = useQueueStore()
function handleFav() {
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || "",
() => null,
() => null
);
favoriteHandler(
queue.currenttrack?.is_favorite,
favType.track,
queue.currenttrack?.trackhash || '',
() => null,
() => null
)
}
</script>
<style lang="scss">
.now-playing-view.isSmall .now-playing-header .nowplaying_title {
padding-left: 0.5rem;
}
.now-playing-header {
padding-bottom: $smaller;
position: relative;
padding-bottom: $smaller;
position: relative;
.nowplaying_title {
padding-left: 1rem;
margin: 1.25rem 0;
.nowplaying_title {
padding-left: 1rem;
margin: 1.25rem 0;
&:last-child {
padding-top: $large;
margin: 1rem 0;
&:last-child {
padding-top: $large;
margin: 1rem 0;
}
@media only screen and (max-width: 724px) {
padding-left: 0.5rem;
}
/* Somehow has to be replaced by above now
@include largePhones {
padding-left: 0.5rem;
}
*/
}
@include largePhones {
padding-left: 0.5rem;
}
}
.below-progress {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
.time {
font-size: $medium;
font-weight: 500;
background-color: $gray3;
padding: 1px $smaller;
min-width: 2.5rem;
text-align: center;
border-radius: $smaller;
font-variant-numeric: tabular-nums;
}
/* Responsive */
@include largePhones {
.right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
}
@include smallestPhones {
position: relative;
flex-direction: column;
align-items: unset;
gap: $small;
.time:first-child {
align-self: baseline;
margin-left: 4px;
}
.time:last-child {
align-self: end;
position: absolute;
top: 0;
right: 4px;
}
.right-group {
width: 100% !important;
.below-progress {
display: flex;
justify-content: space-between;
}
}
}
align-items: center;
margin-top: 1rem;
.centered {
margin: 0 auto;
width: 26rem;
max-width: 100%;
}
.time {
font-size: $medium;
font-weight: 500;
background-color: $gray3;
padding: 1px $smaller;
min-width: 2.5rem;
text-align: center;
border-radius: $smaller;
font-variant-numeric: tabular-nums;
}
.np-image {
position: relative;
margin-bottom: 1rem;
/* Responsive */
@include allPhones {
.right-group button.speaker {
border-top: 1px solid transparent !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
}
img {
width: 100%;
height: 100%;
max-width: 30rem;
// aspect-ratio: 1;
object-fit: cover;
}
}
@include smallestPhones {
position: relative;
flex-direction: column;
align-items: unset;
gap: $small;
#progress {
margin-top: 1rem;
margin-right: 0;
.time:first-child {
align-self: baseline;
margin-left: 4px;
}
&::-moz-range-thumb {
height: 0.8rem;
.time:last-child {
align-self: end;
position: absolute;
top: 0;
right: 4px;
}
.right-group {
width: 100% !important;
display: flex;
justify-content: space-between;
}
}
}
&::-webkit-slider-thumb {
height: 0.8rem;
.centered {
margin: 0 auto;
width: 26rem;
max-width: 100%;
}
&::-ms-thumb {
height: 0.8rem;
.np-image {
position: relative;
margin-bottom: 1rem;
img {
width: 100%;
height: 100%;
max-width: 30rem;
// aspect-ratio: 1;
object-fit: cover;
}
}
#progress {
margin-top: 1rem;
margin-right: 0;
&::-moz-range-thumb {
height: 0.8rem;
}
&::-webkit-slider-thumb {
height: 0.8rem;
}
&::-ms-thumb {
height: 0.8rem;
}
}
}
}
</style>
</style>

View File

@@ -1,108 +1,116 @@
<template>
<div class="now-playing-top">
<router-link class="now-playling-from-link" :to="(data.location as RouteLocationRaw)" title="Go to Play Source">
<div class="from">
<img
v-if="tracklist.from.type === FromOptions.album || tracklist.from.type === FromOptions.artist"
:src="data.image + '.webp'"
:alt="`Now Playing ${tracklist.from.type} image`"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-sm'}`"
/>
<div v-else class="from-icon border rounded-sm">
<component :is="data.icon"></component>
</div>
<div class="pad-sm">
<div class="type">{{ tracklist.from.type }}</div>
<div class="ellip2">{{ data.name }}</div>
</div>
</div>
</router-link>
<button class="options" @click="showContextMenu">
<MoreSvg />
</button>
</div>
<div class="now-playing-top">
<router-link class="now-playling-from-link" :to="(data.location as RouteLocationRaw)" title="Go to Play Source">
<div class="from">
<img
v-if="
tracklist.from.type === FromOptions.album ||
tracklist.from.type === FromOptions.artist ||
tracklist.from.type === FromOptions.mix
"
:src="data.image"
:class="`${tracklist.from.type === FromOptions.artist ? 'circular' : 'rounded-sm'}`"
/>
<div v-else class="from-icon border rounded-sm">
<component :is="data.icon"></component>
</div>
<div class="pad-sm">
<div class="type">{{ tracklist.from.type }}</div>
<div class="ellip2">{{ data.name }}</div>
</div>
</div>
</router-link>
<button class="options" @click="showContextMenu">
<MoreSvg />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { RouteLocationRaw } from "vue-router";
import { computed, ref } from 'vue'
import { RouteLocationRaw } from 'vue-router'
import useTracklist from "@/stores/queue/tracklist";
import useTracklist from '@/stores/queue/tracklist'
import { FromOptions } from "@/enums";
import playingFrom from "@/utils/playingFrom";
import { FromOptions } from '@/enums'
import playingFrom from '@/utils/playingFrom'
import MoreSvg from "@/assets/icons/more.svg";
import { showQueueContextMenu } from "@/helpers/contextMenuHandler";
import MoreSvg from '@/assets/icons/more.svg'
import { showQueueContextMenu } from '@/helpers/contextMenuHandler'
const tracklist = useTracklist();
const tracklist = useTracklist()
const context_showing = ref(false);
const context_showing = ref(false)
const data = computed(() => {
const { name, location, icon, image } = playingFrom(tracklist.from);
return { name, location, icon, image };
});
const { name, location, icon, image } = playingFrom(tracklist.from)
return { name, location, icon, image }
})
function showContextMenu(e: MouseEvent) {
if (!tracklist.tracklist.length) return;
if (!tracklist.tracklist.length) return
showQueueContextMenu(e, context_showing);
showQueueContextMenu(e, context_showing)
}
</script>
<style lang="scss">
.now-playing-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
.options {
transform: rotate(90deg);
.options {
transform: rotate(90deg);
svg {
transform: scale(1.25);
svg {
transform: scale(1.25);
}
}
}
}
.now-playling-from-link {
display: block;
width: fit-content;
display: block;
width: fit-content;
}
.now-playling-from-link > .from {
display: flex;
align-items: center;
img {
width: 2.5rem;
aspect-ratio: 1;
object-fit: cover;
}
.from-icon {
padding: $smaller;
aspect-ratio: 1;
width: 2.5rem;
margin-right: 2px;
display: flex;
align-items: center;
justify-content: center;
background-color: $gray;
border: solid 1px $gray4;
}
.type {
text-transform: capitalize;
font-size: 0.8rem;
color: $gray1;
font-weight: 500;
}
img {
width: 2.5rem;
aspect-ratio: 1;
object-fit: cover;
}
.type + div {
font-weight: 500;
}
.from-icon {
padding: $smaller;
aspect-ratio: 1;
width: 2.5rem;
margin-right: 2px;
display: flex;
align-items: center;
justify-content: center;
background-color: $gray;
border: solid 1px $gray4;
svg {
width: 1.5rem;
color: rgb(202, 197, 197);
}
}
.type {
text-transform: capitalize;
font-size: 0.8rem;
color: $gray1;
font-weight: 500;
}
.type + div {
font-weight: 500;
}
}
</style>

View File

@@ -1,23 +1,33 @@
<template>
<div class="p-after-header">
<div>All Tracks</div>
</div>
<div class="p-after-header">
<div>All Tracks</div>
</div>
</template>
<style lang="scss">
.isSmall .p-after-header {
padding-left: 0.5rem;
}
.p-after-header {
display: flex;
align-items: center;
height: 64px;
padding: 0 1rem;
margin-top: $small;
display: flex;
align-items: center;
height: 64px;
padding: 0 1rem;
margin-top: $small;
font-size: 14px;
font-weight: 500;
color: $gray1;
font-size: 14px;
font-weight: 500;
color: $gray1;
@media only screen and (max-width: 724px) {
padding-left: 0.5rem;
}
/* Somehow has to be replaced by above now
@include largePhones {
padding-left: 0.5rem;
}
*/
}
</style>

View File

@@ -1,20 +1,10 @@
<template>
<div class="last-updated">
<span
v-if="!isHeaderSmall"
class="status"
>Last updated {{ playlist.info._last_updated }}</span
>
<div
v-if="Number.isInteger(playlist.info.id)"
class="edit"
>
&#160;&#160;|&#160;&#160; <span @click="editPlaylist">Edit</span>&#160;&#160;
<span v-if="!isHeaderSmall" class="status">Last updated {{ playlist.info._last_updated }}</span>
<div v-if="Number.isInteger(playlist.info.id)" class="edit">
&#160;&#160;|&#160;&#160; <span @click="editPlaylist">Edit</span>&#160;&#160;
{{ Number.isInteger(playlist.info.id) ? ' | ' : '' }}
<DeleteSvg
class="edit"
@click="deletePlaylist"
/>
<DeleteSvg class="edit" @click="deletePlaylist" />
</div>
</div>
</template>
@@ -45,6 +35,7 @@ function deletePlaylist() {
right: 1rem;
padding: $smaller $small;
font-size: 0.9rem;
font-weight: 500;
border-radius: $smaller;
z-index: 12;
@@ -52,12 +43,15 @@ function deletePlaylist() {
align-items: center;
.edit {
cursor: pointer;
color: $brown;
display: flex;
align-items: center;
}
.edit > span {
cursor: pointer;
color: $brown;
}
svg {
transform: scale(0.75);
margin-bottom: -0.2rem;

View File

@@ -1,9 +1,13 @@
<template>
<router-link :to="{ name: 'PlaylistView', params: { pid: playlist.id } }" class="p-card rounded no-scroll">
<div v-if="!playlist.has_image && playlist.images.length" class="image-grid rounded-sm no-scroll">
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + img['image']" />
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + (img['image'] || img)" />
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<div v-else class="image">
<img :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<img v-else :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<div class="overlay rounded">
<div v-if="playlist.help_text" class="rhelp playlist">
<span class="help">{{ playlist.help_text }}</span>
@@ -20,6 +24,8 @@
<script setup lang="ts">
import { paths } from "../../config";
import { Playlist } from "../../interfaces";
import { playSources } from '@/enums'
import PlayBtn from '../shared/PlayBtn.vue'
const imguri = paths.images.playlist;
defineProps<{
@@ -38,9 +44,14 @@ defineProps<{
height: max-content;
transition: background-color 0.2s ease-out;
.image {
position: relative;
}
.image-grid {
display: grid;
grid: repeat(2, 1fr) / repeat(2, 1fr);
position: relative;
}
&:hover {
@@ -48,6 +59,26 @@ defineProps<{
background-blend-mode: screen;
}
$btnwidth: 4rem;
.play-btn {
opacity: 0;
position: absolute;
width: 4rem;
bottom: $small;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
&:hover {
background-color: $gray4;
.play-btn {
opacity: 1;
transform: translateY(-0.75rem);
}
}
img {
width: 100%;
aspect-ratio: 1;

View File

@@ -21,11 +21,11 @@
<script setup lang="ts">
import useTabStore from '@/stores/tabs'
import AvatarWithDropdown from '@/components/nav/AvatarWithDropdown.vue'
import DashBoard from './Home/Main.vue'
import Queue from './Queue.vue'
import Search from './Search/Main.vue'
import SearchInput from './SearchInput.vue'
import AvatarWithDropdown from '@/components/nav/AvatarWithDropdown.vue'
const tabs = useTabStore()
</script>
@@ -52,6 +52,24 @@ const tabs = useTabStore()
height: 2.5rem;
margin: 1rem;
width: 100%;
#ginner {
button {
width: 2rem;
height: 2rem;
margin-left: 4px;
margin-right: $smallest;
> svg {
width: 1.75rem;
height: 1.75rem;
}
}
> .clear_input {
width: 2rem;
height: 2rem;
}
}
}
.r-content {
@@ -82,6 +100,6 @@ const tabs = useTabStore()
}
.designatedOS .r-sidebar > .r-content > .r-queue > .queue-virtual-scroller > .scroller::-webkit-scrollbar-thumb {
border: 4px solid $gray;
border-color: $gray;
}
</style>

View File

@@ -1,75 +1,87 @@
<template>
<div id="right-tabs" :class="{ tabContent: tabContent }">
<div class="tabheaders">
<button
v-for="tab in tabs"
:key="tab"
class="tab circular"
:class="{ activetab: tab === currentTab }"
@click="$emit('switchTab', tab)"
>
{{ tab }}
</button>
</div>
<div id="right-tabs" :class="{ tabContent: tabContent }">
<div class="tabheaders">
<button
v-for="tab in tabs"
:key="tab"
class="tab circular"
:class="{ activetab: tab === currentTab }"
@click="$emit('switchTab', tab)"
>
{{ tab }}
</button>
</div>
<div v-if="tabContent" id="tab-content" v-auto-animate>
<slot />
<div v-if="tabContent" id="tab-content" v-auto-animate>
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
tabs: string[];
currentTab: string;
tabContent?: boolean;
}>();
tabs: string[]
currentTab: string
tabContent?: boolean
}>()
defineEmits<{
(e: "switchTab", tab: string): void;
}>();
(e: 'switchTab', tab: string): void
}>()
</script>
<style lang="scss">
#right-tabs {
display: grid;
position: absolute; // TODO: Find a way to fix scrollability without using position absolute.
overflow: hidden;
display: grid;
position: absolute; // TODO: Find a way to fix scrollability without using position absolute.
overflow: hidden;
height: 100%;
width: 100%;
height: 100%;
width: 100%;
.tab-buttons-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.tab-buttons-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.vue-recycle-scroller {
padding: 0 $small;
}
.vue-recycle-scroller {
padding: 0 $small;
}
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
}
#tracks-results > .vue-recycle-scroller {
padding: unset;
}
.cardlistrow {
grid-template-columns: repeat(auto-fill, minmax(8.1rem, 1fr));
}
}
#tab-content {
height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.designatedOS #tab-content::-webkit-scrollbar-track {
background-color: $gray;
background-color: $gray;
}
.designatedOS #tab-content::-webkit-scrollbar-thumb {
border: 4px solid $gray;
border-color: $gray;
}
.designatedOS #tab-content .vue-recycle-scroller::-webkit-scrollbar-track {
background-color: $gray;
}
.designatedOS #tab-content .vue-recycle-scroller::-webkit-scrollbar-thumb {
border-color: $gray;
}
#right-tabs.tabContent {
grid-template-rows: min-content 1fr;
grid-template-rows: min-content 1fr;
}
</style>

View File

@@ -1,43 +1,43 @@
<template>
<div class="right-search-top-tracks">
<TrackItem
v-for="(track, index) in search.top_results.tracks"
:key="track.id"
:track="track"
:index="index"
:is-current="false"
:is-current-playing="false"
@play-this="handlePlay(track)"
/>
</div>
<div class="right-search-top-tracks">
<TrackItem
v-for="(track, index) in search.top_results.tracks"
:key="track.id"
:track="track"
:index="index"
:is-current="false"
:is-current-playing="false"
@play-this="handlePlay(track)"
/>
</div>
</template>
<script setup lang="ts">
import { Track } from "@/interfaces";
import { Track } from '@/interfaces'
import useQueueStore from "@/stores/queue";
import useTracklist from "@/stores/queue/tracklist";
import useSearchStore from "@/stores/search";
import useQueueStore from '@/stores/queue'
import useTracklist from '@/stores/queue/tracklist'
import useSearchStore from '@/stores/search'
import TrackItem from "@/components/shared/TrackItem.vue";
import TrackItem from '@/components/shared/TrackItem.vue'
const search = useSearchStore();
const queue = useQueueStore();
const tracklist = useTracklist();
const search = useSearchStore()
const queue = useQueueStore()
const tracklist = useTracklist()
function handlePlay(track: Track) {
queue.clearQueue();
tracklist.setFromSearch(search.query, [track]);
queue.play(0);
queue.clearQueue()
tracklist.setFromSearch(search.query, [track])
queue.play(0)
}
</script>
<style lang="scss">
.right-search-top-tracks {
margin-bottom: 2rem;
margin-bottom: 2rem;
.track-item {
padding: $small;
}
.track-item {
padding: $small 1rem;
}
}
</style>

View File

@@ -31,7 +31,11 @@
@blur.prevent="removeFocusedClass"
@focus.prevent="addFocusedClass"
/>
<div class="clear_input circular noSelect" :class="{ active: search.query.length > 0 }" @click.stop="clearInput">
<div
class="clear_input circular noSelect"
:class="{ active: search.query.length > 0 }"
@click.stop="clearInput"
>
<CancelSvg />
</div>
</div>
@@ -39,57 +43,57 @@
</template>
<script setup lang="ts">
import useSearch from "@/stores/search";
import useSettings from "@/stores/settings";
import useTabStore from "@/stores/tabs";
import { ref } from "vue";
import useSearch from '@/stores/search'
import useSettings from '@/stores/settings'
import useTabStore from '@/stores/tabs'
import { ref } from 'vue'
import CancelSvg from "@/assets/icons/a.svg";
import BackSvg from "@/assets/icons/arrow.svg";
import SearchSvg from "@/assets/icons/search.svg";
import { Routes } from "@/router";
import CancelSvg from '@/assets/icons/a.svg'
import BackSvg from '@/assets/icons/arrow.svg'
import SearchSvg from '@/assets/icons/search.svg'
import { Routes } from '@/router'
const props = defineProps<{
on_nav?: boolean;
}>();
on_nav?: boolean
}>()
const tabs = useTabStore();
const search = useSearch();
const settings = useSettings();
const tabs = useTabStore()
const search = useSearch()
const settings = useSettings()
// HANDLE FOCUS
const inputRef = ref<HTMLInputElement | null>(null);
const inputRef = ref<HTMLInputElement | null>(null)
// NOTE: Functions are used because classes are added to the sorrounding element
// and not the input itself.
function addFocusedClass() {
if (inputRef.value) {
inputRef.value.classList.add("search-focused");
inputRef.value.classList.add('search-focused')
}
}
function removeFocusedClass() {
if (inputRef.value) {
inputRef.value.classList.remove("search-focused");
inputRef.value.classList.remove('search-focused')
}
}
function clearInput() {
search.query = "";
search.query = ''
if (inputRef.value) {
inputRef.value.focus();
inputRef.value.focus()
}
}
// @end
function handleButton() {
if (props.on_nav) return;
if (props.on_nav) return
if (tabs.current === tabs.tabs.search) {
tabs.switchToQueue();
tabs.switchToQueue()
} else {
tabs.switchToSearch();
tabs.switchToSearch()
}
}
</script>
@@ -104,7 +108,7 @@ function handleButton() {
<style lang="scss">
.right > .gsearch-input > #ginner > input {
width: 140px;
width: 150px;
@include allPhones {
width: 100%;
@@ -127,13 +131,15 @@ function handleButton() {
button {
background: transparent;
border: none;
width: 2rem;
height: 2rem;
width: 1.625rem;
height: 1.625rem;
padding: 0;
margin-left: 4px;
margin-left: 6px;
margin-right: $smaller;
border-radius: 3rem;
cursor: pointer;
flex-shrink: 0;
color: $white;
&:hover {
transition: all 0.2s ease;
@@ -179,6 +185,7 @@ function handleButton() {
display: grid;
place-items: center;
flex-shrink: 0;
&:hover {
background-color: $gray;

View File

@@ -0,0 +1,179 @@
<template>
<div class="backup-restore">
<button class="backupnow" @click="doBackup">Backup</button>
<div class="separator"></div>
<h4>Restore backup</h4>
<div class="helptext">
You have {{ backups.length }} backup{{ backups.length !== 1 ? 's' : '' }} in your backup directory.
</div>
<div></div>
<br />
<div class="itemlist">
<div class="item rounded-sm" v-for="backup in backups" :key="backup.name">
<div class="texts">
<div class="item__info">
<div class="item__date">
{{ backup.date }}
<!-- <span class="item__name">{{ backup.name }}</span> -->
</div>
</div>
<div class="item__stats">
<div class="item__playlists">
{{ backup.playlists }} playlist{{ backup.playlists !== 1 ? 's' : '' }}
</div>
•
<div class="item__scrobbles">
{{ backup.scrobbles }} scrobble{{ backup.scrobbles !== 1 ? 's' : '' }}
</div>
•
<div class="item__favorites">
{{ backup.favorites }} favorite{{ backup.favorites !== 1 ? 's' : '' }}
</div>
</div>
</div>
<div class="buttons">
<DeleteSvg @click="() => deleteBackup(backup.name)" />
<button class="restore" @click="() => restore(backup.name)">Restore</button>
</div>
</div>
</div>
<button class="restore-all" @click="() => restore()">Restore All</button>
</div>
</template>
<script setup lang="ts">
import { backupNow, getBackups, restoreBackup, deleteBackup as deleteBackupReq } from '@/requests/settings'
import { onMounted, ref } from 'vue'
import { useToast } from '@/stores/notification'
import DeleteSvg from '@/assets/icons/delete.svg'
const toast = useToast()
interface Backup {
name: string
playlists: number
scrobbles: number
favorites: number
date: string
}
const backups = ref<Backup[]>([])
onMounted(async () => {
backups.value = await getBackups()
})
async function doBackup() {
const res = await backupNow()
if (res.status === 200) {
toast.showSuccess('Backup created')
backups.value.unshift(res.data)
} else {
toast.showError(res.data.msg)
}
}
async function restore(backup_dir?: string) {
const res = await restoreBackup(backup_dir)
if (res.status === 200) {
toast.showSuccess(res.data.msg)
} else {
toast.showError(res.data.msg)
}
}
async function deleteBackup(backup_dir: string) {
const res = await deleteBackupReq(backup_dir)
if (res.status === 200) {
toast.showSuccess(res.data.msg)
backups.value = backups.value.filter(backup => backup.name !== backup_dir)
} else {
toast.showError(res.data.msg)
}
}
</script>
<style lang="scss">
.backup-restore {
position: relative;
.itemlist {
display: grid;
gap: $small;
.item {
display: grid;
grid-template-columns: 1fr 100px;
gap: 1rem;
padding: 1rem;
border: solid 1px $gray3;
align-items: center;
.texts {
display: flex;
flex-direction: column;
gap: $small;
}
}
.item .item__info {
display: flex;
justify-content: space-between;
}
.item .item__info .item__name {
font-size: 0.5rem;
color: $gray3;
font-family: 'SF Mono';
}
.item__stats {
display: flex;
gap: $small;
font-size: 0.8rem;
color: $gray1;
}
}
.restore-all {
width: 100%;
margin-top: 2rem;
}
.backupnow {
position: absolute;
right: -0.5rem;
top: -2.75rem;
}
.separator {
width: calc(100% + 0.5rem);
margin-top: 1rem;
}
.helptext {
font-size: small;
color: $gray1;
font-weight: 400;
margin-top: -0.25rem;
}
.buttons {
display: flex;
gap: 1rem;
align-items: center;
svg {
height: 1.2rem;
cursor: pointer;
}
svg:hover {
color: $red;
}
}
}
</style>

View File

@@ -18,28 +18,28 @@
</template>
<script setup lang="ts">
import DeleteSvg from "@/assets/icons/delete.svg";
import FolderSvg from "@/assets/icons/folder.svg";
import DeleteSvg from '@/assets/icons/delete.svg'
import FolderSvg from '@/assets/icons/folder.svg'
const props = defineProps<{
items: {
title: string;
action: () => void;
}[];
icon: "folder";
}>();
title: string
action: () => void
}[]
icon: 'folder'
}>()
function getIcon() {
switch (props.icon) {
case "folder":
return FolderSvg;
case 'folder':
return FolderSvg
default:
return FolderSvg;
return FolderSvg
}
}
const icon_ = getIcon();
const icon_ = getIcon()
</script>
<style lang="scss">
@@ -63,6 +63,7 @@ const icon_ = getIcon();
gap: 1rem;
svg {
flex-shrink: 0;
width: 1.25rem;
display: block;
}
@@ -71,7 +72,7 @@ const icon_ = getIcon();
display: flex;
gap: $small;
align-items: center;
font-family: "SF Mono", monospace;
font-family: 'SF Mono', monospace;
font-weight: 500;
font-size: 0.9rem;

View File

@@ -6,8 +6,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useToast } from '@/stores/notification'
import { ref } from 'vue'
const toast = useToast()
@@ -48,6 +48,8 @@ async function submit(newValue: number) {
position: relative;
input {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
width: 4rem !important;
border: none;
outline: none;

View File

@@ -0,0 +1,80 @@
<template>
<form class="secretinput" @submit.prevent="$emit('submit', input)">
<div class="left rounded-sm no-scroll">
<input :type="showText ? 'text' : 'password'" v-model="input" @input="() => (showTextManual = true)" />
<button @click.prevent="showTextManual = !showTextManual">
<EyeSvg v-if="showText" />
<EyeSlashSvg v-else />
</button>
</div>
<div class="right">
<button>Save</button>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import EyeSvg from '@/assets/icons/eye.svg'
import EyeSlashSvg from '@/assets/icons/eye.slash.svg'
const props = defineProps<{
text: string
}>()
const input = ref('')
const showTextManual = ref(false)
const showText = computed(() => {
if (showTextManual.value) return true
return input.value.length == 0
})
defineEmits<{
(e: 'submit', value: string): void
}>()
onMounted(() => {
if (props.text) {
input.value = props.text
}
})
</script>
<style lang="scss">
.secretinput {
display: grid;
grid-template-columns: 1fr max-content;
gap: 1rem;
width: 100%;
.left {
display: flex;
align-items: center;
gap: 1rem;
position: relative;
background-color: $gray5;
input {
height: 100%;
width: 100%;
border: none;
outline: none;
background: none;
padding: $small;
font-size: 12px;
font-family: 'SF Mono';
color: #ffffff00;
}
svg {
height: 1rem;
}
button {
background: none;
}
}
}
</style>

View File

@@ -75,6 +75,20 @@
<Accounts v-if="setting.type === SettingType.accounts" />
<About v-if="setting.type === SettingType.about" />
<Pairing v-if="setting.type === SettingType.pairing" />
<DropDown
v-if="setting.type === SettingType.streaming_quality"
:items="(setting.options ?? [] as any)"
:current="(setting.state && setting.state() as any)"
@item-clicked="setting.action"
:reverse="'hide'"
component_key="streaming_quality"
/>
<BackupRestore v-if="setting.type === SettingType.backup" />
<SecretInput
v-if="setting.type === SettingType.secretinput"
:text="setting.state ? setting.state() : ''"
@submit="setting.action"
/>
</div>
</div>
</div>
@@ -87,15 +101,18 @@ import { SettingType } from '@/settings/enums'
import ReloadSvg from '@/assets/icons/reload.svg'
import List from './Components/List.vue'
import LockedNumberInput from './Components/LockedNumberInput.vue'
import NumberInput from './Components/NumberInput.vue'
import Select from './Components/Select.vue'
import SeparatorsInput from './Components/SeparatorsInput.vue'
import Switch from './Components/Switch.vue'
import NumberInput from './Components/NumberInput.vue'
import About from './About.vue'
import Profile from '../modals/settings/Profile.vue'
import Pairing from '../modals/settings/custom/Pairing.vue'
import Accounts from '../modals/settings/custom/Accounts.vue'
import Pairing from '../modals/settings/custom/Pairing.vue'
import DropDown from '../shared/DropDown.vue'
import About from './About.vue'
import BackupRestore from './Components/BackupRestore.vue'
import SecretInput from './Components/SecretInput.vue'
defineProps<{
group: SettingGroup
@@ -178,6 +195,10 @@ defineProps<{
gap: $small;
width: 100%;
button {
padding-right: $medium;
}
button > svg {
transform: scale(0.65);
}

View File

@@ -0,0 +1,139 @@
<template>
<RouterLink :to="getRouterParams()" class="chartitem rounded-sm">
<ArrowSvg class="trend" :class="item.trend?.trend" />
<div class="index">{{ index }}</div>
<img :src="getItemImage(item)" class="chartimage" :class="name" />
<div class="iteminfo">
<div class="title" :title="item.name" v-if="isArtist">
{{ item.name }} <MasterFlag v-if="item.trend?.is_new" :text="item.trend?.is_new ? 'New' : ''" :bitrate="1900"/>
</div>
<div class="title" :title="item.title" v-if="isAlbumOrTrack">
{{ item.title }} <MasterFlag v-if="item.trend?.is_new" :text="item.trend?.is_new ? 'New' : ''" :bitrate="1900"/>
</div>
<div class="artist" v-if="isAlbumOrTrack">
<ArtistName
:artists="item.artists ? item.artists : item.albumartists"
:albumartists="item.albumartists"
/>
</div>
<div class="artist" v-if="isArtist">
{{ item.extra['playcount'] }} track plays
</div>
</div>
<div class="helptext">
{{ item.help_text }}
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { paths } from '@/config'
import { Album, Artist, Track } from '@/interfaces'
import ArrowSvg from '@/assets/icons/arrow.svg'
import ArtistName from '../shared/ArtistName.vue'
import { Routes } from '@/router'
import MasterFlag from '../shared/MasterFlag.vue'
type name = 'artist' | 'album' | 'track'
type ChartItem = Artist | Album | Track
const props = defineProps<{
item: ChartItem
index: number
name: name
}>()
const isArtist = computed(() => props.name === 'artist')
const isAlbumOrTrack = computed(() => props.name === 'album' || props.name === 'track')
function getItemImage(item: ChartItem) {
switch (props.name) {
case 'artist':
return paths.images.artist.medium + item.image
case 'album':
return paths.images.thumb.medium + item.image
case 'track':
return paths.images.thumb.medium + item.image
}
}
function getRouterParams() {
switch (props.name) {
case 'artist':
return { name: Routes.artist, params: { hash: props.item.artisthash } }
default:
return { name: Routes.album, params: { albumhash: props.item.albumhash } }
}
}
</script>
<style lang="scss">
.chartitem {
padding: $small 2rem;
padding-left: 1.25rem;
display: grid;
grid-template-columns: 1rem 1rem max-content 1fr max-content;
gap: 1.5rem;
align-items: center;
margin-bottom: $medium;
.index {
font-size: 1.25rem;
font-weight: 900;
color: $gray2;
text-align: right;
}
.chartimage.artist {
border-radius: 50%;
}
.iteminfo {
.title {
font-size: 1rem;
font-weight: bold;
}
.artist {
font-size: 0.85rem;
color: $gray1;
margin-top: 0.2rem;
}
}
.chartimage {
border-radius: 0.25rem;
height: 48px;
width: auto;
}
.trend {
height: 1.25rem;
}
.trend.rising {
transform: rotate(90deg);
color: rgb(75, 170, 67);
}
.trend.falling {
transform: rotate(-90deg);
color: $red;
}
.is_new {
color: $orange;
}
.helptext {
font-size: 0.75rem;
color: $gray2;
text-align: right;
text-transform: uppercase;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="chartgroup rounded" :class="settings.statsgroup">
<ChartsHeader :name="settings.statsgroup" @change-period="changePeriod" @change-group="changeGroup" :period="settings.statsperiod" />
<br />
<div class="noitems rounded-sm" v-if="items.length === 0">
<div v-if="loading" class="loading">
<div class="spinner"></div>
<span>fetching data...</span>
</div>
<div v-if="!loading && loaded">No {{ settings.statsgroup.slice(0, -1) }} data found for this period</div>
</div>
<ChartItem
v-for="(item, index) in items"
:key="index"
:item="item"
:index="index + 1"
:name="(settings.statsgroup.slice(0, -1) as any)"
/>
<div class="scrobbleinfo rounded-sm">
<div class="date">
<CalendarSvg />
{{ scrobbleInfo?.dates }}
</div>
<div class="scrobbleinfo-trend">
<ArrowSvg class="trend" :class="scrobbleInfo?.trend" />
<div class="text">
{{ scrobbleInfo?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { getChartItem } from '@/requests/stats'
import { Artist, Album, Track } from '@/interfaces'
import useSettings from '@/stores/settings'
import ChartItem from './ChartItem.vue'
import ChartsHeader from './ChartsHeader.vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
import CalendarSvg from '@/assets/icons/calendar.svg'
const settings = useSettings()
// Reactive variables
const loading = ref(true)
const loaded = ref(false)
const items2: any = reactive({
tracks: <Track[]>[],
albums: <Album[]>[],
artists: <Artist[]>[],
})
const items = computed(() => {
return items2[settings.statsgroup]
})
const scrobbleInfo = ref<{
text: string
trend: string
dates: string
} | null>(null)
// Functions
async function getItems() {
items2[settings.statsgroup] = []
loaded.value = false
let isPending = true
// Set a timeout to show the loader after 250ms
setTimeout(() => {
if (isPending) {
loading.value = true
}
}, 450)
try {
const res = await getChartItem(settings.statsgroup, settings.statsperiod, 10, 'playduration')
items2[settings.statsgroup] = res.data[settings.statsgroup]
scrobbleInfo.value = res.data.scrobbles
loaded.value = true
} finally {
isPending = false
loading.value = false
loaded.value = true
}
}
async function changePeriod(newPeriod: string) {
settings.setStatsPeriod(newPeriod)
await getItems()
}
async function changeGroup(newGroup: string) {
settings.setStatsGroup(newGroup)
await getItems()
}
onMounted(async () => {
await getItems()
})
</script>
<style lang="scss">
.chartgroup {
.loading {
display: flex;
gap: $small;
}
.noitems {
height: 3.25rem;
padding: 1rem;
background-color: $gray;
margin: 1rem;
margin-bottom: 2rem;
color: $white;
}
.scrobbleinfo {
display: flex;
align-items: center;
justify-content: space-between;
gap: $small;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 900;
margin: $medium 1.2rem;
color: $gray1;
.date {
display: flex;
align-items: center;
gap: $small;
svg {
width: 1.25rem;
}
}
.scrobbleinfo-trend {
color: #8280f0;
display: flex;
align-items: center;
gap: $small;
}
.trend {
width: 1.25rem;
}
.trend.rising {
transform: rotate(90deg);
color: rgb(75, 170, 67);
}
.trend.falling {
transform: rotate(-90deg);
color: $red;
}
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div
class="chartitem chartitemhashuno rounded"
:style="{
backgroundColor: name === 'artist' ? '' : color,
}"
>
<div
v-if="name === 'artist'"
class="gradient"
:style="`background-image: linear-gradient(to right, transparent 0, ${item.color} 12rem, ${item.color} 100%)`"
></div>
<div class="hashuno shadow-sm">
1
</div>
<img :src="getItemImage(item)" class="rounded-sm" :class="name" />
<div class="iteminfo">
<div>
<div class="helptext">
{{ item.help_text }}
</div>
<div class="artist" v-if="name !== 'artist'">
<ArtistName
:artists="item.artists ? item.artists : item.albumartists"
:albumartists="item.albumartists"
/>
</div>
<div class="title ellip">{{ name === 'artist' ? item.name : item.title }}</div>
</div>
<!-- <div class="index">
<ArrowSvg class="trend" :class="item.trend" /> 1
</div> -->
</div>
<PlayBtn />
</div>
</template>
<script setup lang="ts">
import Vibrant from 'node-vibrant'
import { onMounted, ref } from 'vue'
import { paths } from '@/config'
import { Artist, Album, Track } from '@/interfaces'
import listToRgbString from '@/utils/colortools/listToRgbString'
import ArrowSvg from '@/assets/icons/arrow.svg'
import ArtistName from '../shared/ArtistName.vue'
import PlayBtn from '../shared/PlayBtn.vue'
type name = 'artist' | 'album' | 'track'
type ChartItem = Artist | Album | Track
const props = defineProps<{
item: ChartItem
index: number
name: name
}>()
const color = ref<string | null>(null)
function getItemImage(item: ChartItem, size: 'small' | 'large' | 'medium' = 'large') {
switch (props.name) {
case 'artist':
return paths.images.artist[size] + item.image
case 'album':
return paths.images.thumb[size] + item.image
case 'track':
return paths.images.thumb[size] + item.image
}
}
onMounted(() => {
if (props.name === 'artist') return
const imageurl = getItemImage(props.item)
const vibrant = new Vibrant(imageurl)
vibrant.getPalette().then(palette => {
color.value = listToRgbString(palette.DarkMuted?.getRgb())
})
})
</script>
<style lang="scss">
.chartitemhashuno {
display: grid;
grid-template-columns: max-content 1fr max-content !important;
align-items: flex-end !important;
margin: 1rem;
margin-top: 0;
// padding: 1rem !important;
position: relative;
.hashuno {
background-color: #fff;
color: #000;
position: absolute;
bottom: -1rem;
left: 2rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: bold;
}
.iteminfo {
width: max-content;
display: grid;
grid-template-columns: max-content 1fr;
align-items: flex-end;
// gap: 1rem;
.index {
font-size: 5rem;
font-weight: 900;
color: #fff;
}
.trend {
color: $gray1;
height: 1.5rem;
}
.helptext {
text-align: left
}
.title {
font-size: 2rem !important;
}
}
img {
height: 8rem;
width: 8rem;
object-fit: cover;
}
img.artist {
height: 10rem;
width: 15rem;
object-fit: cover;
margin-left: -1rem;
margin-top: -1rem;
}
.gradient {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="stats-charts">
<GenericHeader>
<template #name>Charts</template>
<template #description>Your top artists, albums, and tracks</template>
</GenericHeader>
<br>
<div class="chartitemgroupsgrid">
<ChartItemGroup />
</div>
</div>
</template>
<script setup lang="ts">
import GenericHeader from '@/components/shared/GenericHeader.vue'
import ChartItemGroup from './ChartItemGroup.vue'
</script>
<style lang="scss">
.stats-charts {
.chartitemgroupsgrid {
display: grid;
grid-template-columns: 1fr;
gap: 3rem;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="chartheader">
<!-- <div class="title">{{ name }}</div> -->
<div class="group">
<div
class="group-item"
v-for="g in groups"
:key="g"
:class="g === name ? 'active' : ''"
@click="$emit('changeGroup', g)"
>
{{ g }}
</div>
</div>
<div class="period">
<div
class="period-item"
v-for="p in periods"
:key="p"
:class="p === period ? 'active' : ''"
@click="$emit('changePeriod', p)"
>
{{ p }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
period: string
}>()
defineEmits<{
(e: 'changePeriod', period: string): void
(e: 'changeGroup', group: string): void
}>()
const groups = ['artists', 'albums', 'tracks']
const periods = ['week', 'month', 'year', 'alltime']
</script>
<style lang="scss">
.chartheader {
padding: $smaller 0 1rem 0;
margin: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: solid 1px $gray5;
// margin-left: -1rem;
.title {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.period,
.group {
display: flex;
gap: 1rem;
text-transform: uppercase;
.period-item,
.group-item {
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
color: $gray2;
&.active {
color: $white;
}
}
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div class="statitem" :class="props.icon">
<svg
class="noise"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svgjs="http://svgjs.dev/svgjs"
viewBox="0 0 700 700"
>
<defs>
<filter
id="nnnoise-filter"
x="-20%"
y="-20%"
width="140%"
height="140%"
filterUnits="objectBoundingBox"
primitiveUnits="userSpaceOnUse"
color-interpolation-filters="linearRGB"
>
<feTurbulence
type="turbulence"
baseFrequency="0.05"
numOctaves="4"
seed="15"
stitchTiles="stitch"
x="0%"
y="0%"
width="100%"
height="100%"
result="turbulence"
></feTurbulence>
<feSpecularLighting
surfaceScale="21"
specularConstant="1.7"
specularExponent="20"
lighting-color="#7957A8"
x="0%"
y="0%"
width="100%"
height="100%"
in="turbulence"
result="specularLighting"
>
<feDistantLight azimuth="3" elevation="84"></feDistantLight>
</feSpecularLighting>
</filter>
</defs>
<rect width="700" height="700" fill="transparent"></rect>
<rect width="700" height="700" fill="#7957a8" filter="url(#nnnoise-filter)"></rect>
</svg>
<div class="itemcontent">
<div class="count ellip2" :title="formattedValue">{{ formattedValue }}</div>
<div class="title">{{ text }}</div>
</div>
<component :is="icon" class="staticon" v-if="!props.icon.startsWith('top')" />
<router-link
:to="{
name: Routes.album,
params: {
albumhash: props.image?.replace('.webp', ''),
},
}"
v-if="props.icon.startsWith('top') && props.image"
>
<img class="staticon statimage shadow-sm" :src="paths.images.thumb.small + props.image" alt="" />
</router-link>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import StopWatchSvg from '@/assets/icons/timer.svg'
import HeadphoneSvg from '@/assets/icons/headphones.svg'
import FolderSvg from '@/assets/icons/folder.nopad.svg'
import Index1Svg from '@/assets/icons/index1.svg'
import SparklesSvg from '@/assets/icons/sparkles.svg'
import { paths } from '@/config'
import { Routes } from '@/router'
const props = defineProps<{
value: string
text: string
icon: string
image?: string
}>()
const icon = computed(() => {
switch (props.icon) {
case 'streams':
return HeadphoneSvg
case 'playtime':
return StopWatchSvg
case 'trackcount':
return FolderSvg
case 'toptrack':
return Index1Svg
default:
return SparklesSvg
}
})
const formattedValue = computed(() => {
return props.value.toLocaleString()
})
</script>
<style lang="scss">
.statitem {
// background-color: $gray2;
// padding: 1rem;
border-radius: 1rem;
height: 12rem;
aspect-ratio: 1;
overflow: hidden;
background-image: linear-gradient(to top right, rgb(120, 76, 129), #9643da91, rgb(132, 80, 228));
position: relative;
&.streams {
background-image: linear-gradient(to top, #c79081 0%, #dfa579 100%);
}
&.playtime {
background-image: linear-gradient(-225deg, #3d4e81 0%, #5753c9 48%, #6e7ff3 100%);
}
&.trackcount {
background-image: linear-gradient(to top, #6a66b9 0%, #7777db 52%, #7b7bd4 100%);
}
&.toptrack {
background-image: linear-gradient(-225deg, #65379b 0%, #6750b3 53%, #6457c6 100%);
}
.itemcontent {
position: relative;
z-index: 1;
height: 100%;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: $small;
.count {
font-size: 1.55rem;
font-weight: 900;
}
.title {
font-size: 14px;
font-weight: 500;
}
}
.staticon {
position: absolute;
top: 1rem;
left: 1rem;
width: 1.5rem;
z-index: 1;
}
.statimage {
height: 54px;
width: 54px;
border-radius: $smaller;
}
svg.noise {
position: absolute;
top: 0;
left: 0;
}
}
.statitem.toptrack,
.statitem.topalbum {
aspect-ratio: 1.5;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="statshead" v-if="statItems.length">
<div class="left">
<StatItem
v-for="item in statItems.slice(0, statItems.length - 1)"
:key="item.cssclass"
:value="item.value"
:text="item.text"
:icon="item.cssclass"
:image="item.image"
/>
</div>
<div class="right">
<StatItem
:value="statItems[statItems.length - 1].value"
:text="statItems[statItems.length - 1].text"
:icon="statItems[statItems.length - 1].cssclass"
:image="statItems[statItems.length - 1].image"
/>
</div>
</div>
<div class="statsdates" v-if="date">
<CalendarSvg />
{{ date }}
</div>
</template>
<script setup lang="ts">
import { getStats } from '@/requests/stats'
import { onMounted, ref } from 'vue'
import StatItem from './StatItem.vue'
import CalendarSvg from '@/assets/icons/calendar.svg'
interface StatItem {
cssclass: string
value: string
text: string
image?: string
}
const props = defineProps<{
items?: StatItem[]
}>()
const statItems = ref<StatItem[]>([])
const date = ref<string | null>(null)
onMounted(async () => {
if (props.items) {
statItems.value = props.items
return
}
const res = await getStats()
if (res.status == 200) {
statItems.value = res.data.stats
date.value = res.data.dates
}
})
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss">
.statshead {
display: grid;
grid-template-columns: 1fr max-content;
overflow-x: auto;
gap: 1.5rem;
padding: 1rem;
.left {
display: flex;
gap: 2rem;
}
.streamduration {
padding: 1rem;
}
}
.statsdates {
display: flex;
align-items: center;
gap: $small;
color: $gray1;
padding: 1rem;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 900;
svg {
width: 1.25rem;
}
}
</style>

View File

@@ -22,6 +22,12 @@
@hideModal="hideModal"
@setTitle="setTitle"
/>
<CrudPage
v-if="modal.component == modal.options.page"
@hideModal="hideModal"
@setTitle="setTitle"
v-bind="modal.props"
/>
<UpdatePlaylist
v-if="modal.component == modal.options.updatePlaylist"
v-bind="modal.props"
@@ -43,46 +49,47 @@
</template>
<script setup lang="ts">
import { deletePlaylist as delPlaylist } from "@/requests/playlists";
import useModalStore, { ModalOptions } from "@/stores/modal";
import { useRouter } from "vue-router";
import { deletePlaylist as delPlaylist } from '@/requests/playlists'
import useModalStore, { ModalOptions } from '@/stores/modal'
import { useRouter } from 'vue-router'
import AuthLogin from "./modals/AuthLogin.vue";
import ConfirmModal from "./modals/ConfirmModal.vue";
import NewPlaylist from "./modals/NewPlaylist.vue";
import RootDirsPrompt from "./modals/RootDirsPrompt.vue";
import SetRootDirs from "./modals/SetRootDirs.vue";
import Settings from "./modals/Settings.vue";
import UpdatePlaylist from "./modals/updatePlaylist.vue";
import AuthLogin from './modals/AuthLogin.vue'
import ConfirmModal from './modals/ConfirmModal.vue'
import CrudPage from './modals/CrudPage.vue'
import NewPlaylist from './modals/NewPlaylist.vue'
import RootDirsPrompt from './modals/RootDirsPrompt.vue'
import SetRootDirs from './modals/SetRootDirs.vue'
import Settings from './modals/Settings.vue'
import UpdatePlaylist from './modals/updatePlaylist.vue'
const modal = useModalStore();
const router = useRouter();
const modal = useModalStore()
const router = useRouter()
function setTitle(title: string) {
modal.setTitle(title);
modal.setTitle(title)
}
function hideModal() {
modal.hideModal();
modal.hideModal()
}
function deletePlaylist() {
delPlaylist(modal.props.pid)
.then(() => modal.hideModal())
.then(() => router.back());
.then(() => router.back())
}
</script>
<style lang="scss">
.modal {
position: fixed;
z-index: 20;
z-index: 21;
height: 100vh;
width: 100vw;
display: grid;
place-items: center;
input[type="search"] {
input[type='search'] {
margin: $small 0;
border: none;
background-color: $gray5;

View File

@@ -1,12 +1,6 @@
<template>
<div
class="loginmodal"
v-auto-animate
>
<div
class="head"
:class="{ selected }"
>
<div class="loginmodal" v-auto-animate>
<div class="head" :class="{ selected }">
<button
class="back rounded-sm"
title="Back to selection"
@@ -18,50 +12,24 @@
<span>back</span> <ArrowSvg />
</button>
<Logo />
<button
class="back back2 rounded-sm"
title="Back to selection"
>
<span>back</span> <ArrowSvg />
</button>
<button class="back back2 rounded-sm" title="Back to selection"><span>back</span> <ArrowSvg /></button>
</div>
<div class="alcontent">
<div class="helptext" v-if="!selected">
<div class="h2">Welcome back</div>
</div>
<div
class="selected-user"
v-if="selected"
>
<div class="selected-user" v-if="selected">
<User
:user="
selected.username === ''
? { id: 0, username: username, firstname: '' }
: selected
"
:user="selected.username === '' ? { id: 0, username: username, firstname: '' } : selected"
:selected="true"
/>
</div>
<div
class="userlist"
v-auto-animate
v-else
>
<User
v-for="user in shownUsers"
@click="setUser(user)"
:user="user"
:key="user.id"
/>
<div class="userlist" v-auto-animate v-else>
<User v-for="user in shownUsers" @click="setUser(user)" :user="user" :key="user.id" />
</div>
<form
class="passinput"
v-if="selected"
v-auto-animate
@submit.prevent="loginUser"
>
<form class="passinput" v-if="selected" v-auto-animate @submit.prevent="loginUser">
<!-- Only show username input if there's no user list -->
<Input
placeholder="Enter username"
@@ -76,19 +44,10 @@
@input="(input: string) => password = input"
/>
<!-- v-if="username.length && password.length" -->
<button
class="submit"
:class="{ long: selected.username !== ''}"
>
Login
</button>
<button class="submit" :class="{ long: selected.username !== '' }">Login</button>
</form>
</div>
<div
v-if="guestAllowed"
class="guestlink"
@click="() => guestLogin()"
>
<div v-if="guestAllowed" class="guestlink" @click="() => guestLogin()">
<span>Or continue as guest </span>
</div>
</div>
@@ -97,14 +56,14 @@
<script setup lang="ts">
import { Ref, computed, nextTick, onMounted, ref } from 'vue'
import useAuth from '@/stores/auth'
import { UserSimplified } from '@/interfaces'
import { getAllUsers } from '@/requests/auth'
import useAuth from '@/stores/auth'
import Logo from '../Logo.vue'
import User from '../shared/LoginUserCard.vue'
import ArrowSvg from '../../assets/icons/expand.svg'
import Logo from '../Logo.vue'
import Input from '../shared/Input.vue'
import User from '../shared/LoginUserCard.vue'
const auth = useAuth()
@@ -112,12 +71,10 @@ const username = ref('')
const password = ref('')
const users: Ref<UserSimplified[]> = ref([])
const shownUsers = computed(() => users.value.filter((user) => user.username !== 'guest'))
const shownUsers = computed(() => users.value.filter(user => user.username !== 'guest'))
const selected = ref<UserSimplified | null>(null)
const guestAllowed = computed(() =>
users.value.some((user) => user.username === 'guest')
)
const guestAllowed = computed(() => users.value.some(user => user.username === 'guest'))
async function setUser(user: UserSimplified) {
selected.value = user
@@ -147,10 +104,7 @@ async function loginUser() {
await auth.login(username.value, password.value)
}
async function guestLogin(
username: string = 'guest',
password: string = 'guest'
) {
async function guestLogin(username: string = 'guest', password: string = 'guest') {
await auth.login(username, password)
}
@@ -158,15 +112,12 @@ onMounted(async () => {
let res = await getAllUsers()
// if there are no users, or only the guest user, set the user to empty user
if (
res.users.length === 0 ||
(res.users.length == 1 && res.users[0].username === 'guest')
) {
if (res.users.length === 0 || (res.users.length == 1 && res.users[0].username === 'guest')) {
setUser({ id: 0, username: '', firstname: '' })
}
// if there's only one user, and it's not the guest user, select them
if (res.users.filter((user) => user.username !== 'guest').length === 1) {
if (res.users.filter(user => user.username !== 'guest').length === 1) {
setTimeout(() => {
setUser(res.users[0])
}, 250)
@@ -186,8 +137,14 @@ onMounted(async () => {
display: grid;
grid-template-rows: max-content 1fr max-content;
max-height: calc(100vh - 4rem);
.alcontent {
padding-bottom: 2rem;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.guestlink {
@@ -281,14 +238,14 @@ onMounted(async () => {
align-items: center;
input {
font-size: 1rem;
width: 100%;
height: 3rem;
padding: 1rem;
font-size: 1rem;
border: none;
outline: none;
background-color: $gray5;
color: $gray1;
color: $white;
text-align: center;
}
@@ -299,6 +256,11 @@ onMounted(async () => {
height: 3rem;
background-color: $darkblue;
margin-top: 1rem;
transition: color 0.2s ease-out;
&:hover {
color: #ffffff;
}
}
.submit.long {

View File

@@ -1,32 +1,36 @@
<template>
<div class="confirm-modal">
<div class="t-center" style="padding: 0 4rem">{{ text }}</div>
<div class="buttons">
<button class="cancel" @click="cancelAction">Cancel</button>
<button class="confirm" @click="confirmAction">Delete</button>
<div class="confirm-modal">
<div class="t-center" style="padding: 0 4rem">{{ text }}</div>
<div class="buttons">
<button class="cancel" @click="cancelAction">Cancel</button>
<button class="confirm" @click="confirmAction">Delete</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
text: string;
confirmAction: () => void;
cancelAction: () => void;
}>();
text: string
confirmAction: () => void
cancelAction: () => void
}>()
</script>
<style lang="scss">
.confirm-modal {
.buttons {
margin-top: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.t-center {
font-weight: 500;
}
.confirm {
background: $red;
}
.buttons {
margin-top: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.confirm {
background: $red;
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<form action="" v-if="delete">
<div>Are you sure you want to delete this page?</div>
<br />
<button @click.prevent="submit" class="critical">Yes, Delete</button>
</form>
<form class="playlist-modal" @submit.prevent="submit" v-else>
<label for="name">Page name</label>
<br />
<input type="search" class="rounded-sm" id="name" :value="page?.name" />
<br />
<label for="description">Description</label>
<br />
<input type="search" class="rounded-sm" id="description" :value="page?.extra.description" />
<br /><br />
<button type="submit">{{ page ? 'Update' : 'Create' }}</button>
</form>
</template>
<script setup lang="ts">
import { Page } from '@/interfaces'
import { createNewPage, deletePage, updatePage } from '@/requests/pages'
import { router } from '@/router';
import { NotifType, Notification } from '@/stores/notification'
const props = defineProps<{
page?: Page
hash?: string
type?: string
extra?: any
delete?: boolean
}>()
const emit = defineEmits<{
(e: 'hideModal'): void
(e: 'setTitle', title: string): void
}>()
emit('setTitle', props.page ? (props.delete ? 'Delete Page' : 'Update Page') : 'New Page')
async function submit(e: Event) {
if (props.delete && props.page) {
const deleted = await deletePage(props.page.id)
if (deleted) {
new Notification('Page deleted', NotifType.Success)
emit('hideModal')
router.push('/')
}
return
}
e.preventDefault()
const name = (e.target as any).elements['name'].value
const description = (e.target as any).elements['description'].value
// If the page is null, we are creating a new page
if (props.page == null) {
const created = await createNewPage(name, description, [
{
hash: props.hash as string,
type: props.type as string,
extra: props.extra,
},
])
if (created) {
new Notification('New page created', NotifType.Success)
emit('hideModal')
}
} else {
const updatedPage = await updatePage(props.page, name, description)
if (updatedPage) {
props.page.name = updatedPage.name
props.page.extra.description = updatedPage.extra.description
new Notification('Page updated', NotifType.Success)
emit('hideModal')
}
}
}
</script>

View File

@@ -19,7 +19,7 @@
</button>
{{ currentGroup?.title }}
<span v-if="currentGroup?.experimental" class="badge experimental circular">
{{ currentGroup?.experimental ? "experimental" : "" }}
{{ currentGroup?.experimental ? 'experimental' : '' }}
</span>
</div>
</div>
@@ -29,49 +29,49 @@
</template>
<script setup lang="ts">
import settingGroups from "@/settings";
import settingGroups from '@/settings'
import ArrowSvg from "@/assets/icons/arrow.svg";
import { SettingGroup } from "@/interfaces/settings";
import { isSmallPhone } from "@/stores/content-width";
import { computed, ref } from "vue";
import Content from "./settings/Content.vue";
import Sidebar from "./settings/Sidebar.vue";
import ArrowSvg from '@/assets/icons/arrow.svg'
import { SettingGroup } from '@/interfaces/settings'
import { isSmallPhone } from '@/stores/content-width'
import { computed, ref } from 'vue'
import Content from './settings/Content.vue'
import Sidebar from './settings/Sidebar.vue'
const emit = defineEmits<{
(e: "setTitle", title: string): void;
}>();
(e: 'setTitle', title: string): void
}>()
const currentTab = ref<string>("");
const currentTab = ref<string>('')
const currentGroup = computed(() => {
for (const group of settingGroups) {
for (const settings of group.groups) {
if (settings.title === currentTab.value) {
return settings;
return settings
}
}
}
if (isSmallPhone.value) {
return null;
return null
}
// select default tab
for (const group of settingGroups) {
for (const settings of group.groups) {
if (settings.title === "Appearance") {
return settings;
if (settings.title === 'Appearance') {
return settings
}
}
}
});
})
const showContent = computed(() => {
return currentGroup.value !== null;
});
return currentGroup.value !== null
})
function handleGoBack() {
currentTab.value = "";
currentTab.value = ''
}
</script>
@@ -81,12 +81,10 @@ $modalheight: 38rem;
.settingsmodal {
display: grid;
grid-template-columns: 15rem 1fr;
height: $modalheight;
.content {
display: grid;
grid-template-rows: 4rem 1fr;
height: $modalheight;
.head {
border-bottom: solid 1px $gray4;

View File

@@ -3,60 +3,26 @@
<div class="profileavatar">
<Avatar :name="username || auth.user.username" />
<div class="name">
{{
adding_user
? username
: `Hi ${auth.user.username}`
}}
{{ adding_user ? username : `Hi ${auth.user.username}` }}
</div>
<div
class="roles"
v-if="!adding_user"
>
<span
class="role"
v-for="role in auth.user.roles"
:key="role"
>
{{ role }}</span
>
<div class="roles" v-if="!adding_user">
<span class="role" v-for="role in auth.user.roles" :key="role"> {{ role }}</span>
</div>
</div>
<form
class="updateprof"
v-auto-animate
@submit.prevent="handleSubmit"
>
<form class="updateprof" v-auto-animate @submit.prevent="handleSubmit">
<div class="names">
<label for="username">Username</label>
<Input
:placeholder="adding_user ? 'username' : auth.user.username"
@input="(input) => (username = input)"
@input="input => (username = input)"
/>
</div>
<label for="pswd"
>{{ adding_user ? 'Create' : 'Change' }} password</label
>
<Input
type="password"
placeholder="⏺⏺⏺⏺⏺⏺⏺⏺"
@input="(input) => (password = input)"
/>
<div
class="confirmpassword"
v-if="password.length"
>
<label for="pswd">{{ adding_user ? 'Create' : 'Change' }} password</label>
<Input type="password" placeholder="⏺⏺⏺⏺⏺⏺⏺⏺" @input="input => (password = input)" />
<div class="confirmpassword" v-if="password.length">
<label for="confirmpswd">Confirm password</label>
<Input
type="password"
placeholder="⏺⏺⏺⏺⏺⏺⏺⏺"
@input="(input) => (confirmPassword = input)"
/>
<label
class="error"
v-if="errorText"
>{{ errorText }}</label
>
<Input type="password" placeholder="⏺⏺⏺⏺⏺⏺⏺⏺" @input="input => (confirmPassword = input)" />
<label class="error" v-if="errorText">{{ errorText }}</label>
</div>
<button v-if="showSubmit">
{{ adding_user ? 'Add user' : 'Update' }}
@@ -89,19 +55,13 @@ const confirmPassword = ref('')
const showSubmit = computed(() => {
if (props.adding_user) {
return (
username.value.length &&
password.value.length &&
confirmPassword.value.length &&
!errorText.value
)
return username.value.length && password.value.length && confirmPassword.value.length && !errorText.value
}
// show submit button if:
// username has changed
// password has changed and is confirmed
return (
(!confirmPassword.value.length ||
(confirmPassword.value && !errorText.value)) &&
(!confirmPassword.value.length || (confirmPassword.value && !errorText.value)) &&
(payload.value.username || payload.value.password)
)
})
@@ -112,10 +72,7 @@ const errorText = computed(() => {
return ''
}
if (
confirmPassword.value.length &&
password.value !== confirmPassword.value
) {
if (confirmPassword.value.length && password.value !== confirmPassword.value) {
return 'Passwords do not match'
}
})
@@ -200,7 +157,8 @@ onMounted(async () => {
label {
margin-bottom: 0.5rem;
font-size: 14px;
font-weight: 500;
font-size: 0.9rem;
color: $gray1;
}

View File

@@ -5,7 +5,7 @@
class="group"
v-for="group in settingGroups.filter(g => {
// return true
return g.show_if ? g.show_if() : true;
return g.show_if ? g.show_if() : true
})"
:key="group.title"
>
@@ -36,21 +36,21 @@
</template>
<script setup lang="ts">
import { SettingGroup } from "@/interfaces/settings";
import settingGroups from "@/settings";
import useAuth from "@/stores/auth";
import { SettingGroup } from '@/interfaces/settings'
import settingGroups from '@/settings'
import useAuth from '@/stores/auth'
import Avatar from "@/components/shared/Avatar.vue";
import Avatar from '@/components/shared/Avatar.vue'
const auth = useAuth();
const auth = useAuth()
defineProps<{
currentGroup: SettingGroup;
}>();
currentGroup: SettingGroup
}>()
defineEmits<{
(e: "setTab", title: string): void;
}>();
(e: 'setTab', title: string): void
}>()
</script>
<style lang="scss">
@@ -101,7 +101,7 @@ defineEmits<{
.gtitle {
font-weight: bold;
font-size: 14px;
margin: 1rem 0 $smaller $small;
margin: 1.25rem 0 $smaller $small;
}
.gitems {
@@ -154,7 +154,7 @@ defineEmits<{
}
&.about::before {
content: "";
content: '';
height: 1px;
position: absolute;
top: -$small;

View File

@@ -15,7 +15,7 @@
<div class="h2">All users</div>
<button class="adduser" @click="showAddUser = true">
<PlusSvg />
new user
New user
</button>
</div>
<TransitionGroup name="list">
@@ -68,145 +68,145 @@
</template>
<script setup lang="ts">
import { User } from "@/interfaces";
import { getAllUsers } from "@/requests/auth";
import { updateConfig } from "@/requests/settings";
import { SettingType } from "@/settings/enums";
import { onMounted, ref } from "vue";
import { User } from '@/interfaces'
import { getAllUsers } from '@/requests/auth'
import { updateConfig } from '@/requests/settings'
import { SettingType } from '@/settings/enums'
import { onMounted, ref } from 'vue'
import useAuth from "@/stores/auth";
import { useToast } from "@/stores/notification";
import useAuth from '@/stores/auth'
import { useToast } from '@/stores/notification'
import DeleteSvg from "@/assets/icons/delete.svg";
import PlusSvg from "@/assets/icons/plus.svg";
import Avatar from "@/components/shared/Avatar.vue";
import Profile from "../Profile.vue";
import ToggleSetting from "./ToggleSetting.vue";
import DeleteSvg from '@/assets/icons/delete.svg'
import PlusSvg from '@/assets/icons/plus.svg'
import Avatar from '@/components/shared/Avatar.vue'
import Profile from '../Profile.vue'
import ToggleSetting from './ToggleSetting.vue'
const auth = useAuth();
const toast = useToast();
const auth = useAuth()
const toast = useToast()
const selectedUser = ref(0);
const users = ref(<User[]>[]);
const showAddUser = ref(false);
const selectedUser = ref(0)
const users = ref(<User[]>[])
const showAddUser = ref(false)
const settingsMap = {
enableGuest: ref(false),
usersOnLogin: ref(false),
} as { [key: string]: { value: boolean } };
} as { [key: string]: { value: boolean } }
const account_settings = [
{
title: "Enable guest access",
desc: "Allow users to access the site without an account",
title: 'Enable guest access',
desc: 'Allow users to access the site without an account',
type: SettingType.binary,
value: settingsMap.enableGuest,
action: async () => {
if (settingsMap.enableGuest.value) {
const success = await auth.deleteUser("guest");
const success = await auth.deleteUser('guest')
if (success) {
settingsMap.enableGuest.value = !settingsMap.enableGuest.value;
settingsMap.enableGuest.value = !settingsMap.enableGuest.value
}
return;
return
}
settingsMap.enableGuest.value = await auth.addGuestUser();
settingsMap.enableGuest.value = await auth.addGuestUser()
},
},
{
title: "Show users on login",
desc: "Show a list of users on your server when logging in",
title: 'Show users on login',
desc: 'Show a list of users on your server when logging in',
type: SettingType.binary,
value: settingsMap.usersOnLogin,
action: async () => {
const res = await updateConfig("usersOnLogin", !settingsMap.usersOnLogin.value);
const res = await updateConfig('usersOnLogin', !settingsMap.usersOnLogin.value)
if (res.status === 200) {
settingsMap.usersOnLogin.value = !settingsMap.usersOnLogin.value;
return;
settingsMap.usersOnLogin.value = !settingsMap.usersOnLogin.value
return
}
if (res.data.msg) {
return toast.showError(res.data.msg);
return toast.showError(res.data.msg)
}
toast.showGenericError();
toast.showGenericError()
},
},
];
]
const usettings = [
{
title: "Admin",
desc: "Can do anything",
title: 'Admin',
desc: 'Can do anything',
value: (roles: string[]) => {
return roles.includes("admin");
return roles.includes('admin')
},
action: async (user: User) => {
let initialRoles = [...user.roles];
let roles = [...user.roles];
let initialRoles = [...user.roles]
let roles = [...user.roles]
if (roles.includes("admin")) {
roles = roles.filter(r => r !== "admin");
if (roles.includes('admin')) {
roles = roles.filter(r => r !== 'admin')
} else {
roles.push("admin");
roles.push('admin')
}
const success = await auth.updateProfile({
id: user.id,
roles: roles,
});
})
if (success) {
user.roles = roles;
user.roles = roles
} else {
user.roles = initialRoles;
user.roles = initialRoles
}
},
},
];
]
async function deleteUser(user: User) {
if (user.username === auth.user.username) {
return toast.showError("Sorry! You cannot delete yourself");
return toast.showError('Sorry! You cannot delete yourself')
}
const success = await auth.deleteUser(user.username);
const success = await auth.deleteUser(user.username)
if (success) {
setTimeout(() => {
users.value = users.value.filter(u => u.id !== user.id);
}, 500);
users.value = users.value.filter(u => u.id !== user.id)
}, 500)
}
}
function userAdded(user: User) {
showAddUser.value = false;
showAddUser.value = false
setTimeout(() => {
// insert user after last admin
const lastAdmin = users.value.findIndex(u => u.roles.includes("admin"));
users.value.splice(lastAdmin + 1, 0, user);
}, 250);
const lastAdmin = users.value.findIndex(u => u.roles.includes('admin'))
users.value.splice(lastAdmin + 1, 0, user)
}, 250)
}
function selectUser(id: number) {
if (selectedUser.value === id) {
selectedUser.value = 0;
return;
selectedUser.value = 0
return
}
selectedUser.value = id;
selectedUser.value = id
}
onMounted(async () => {
const res = await getAllUsers(false);
const res = await getAllUsers(false)
if (res.users) {
// remove guest user from list
res.users = res.users.filter(u => u.username !== "guest");
users.value = res.users;
res.users = res.users.filter(u => u.username !== 'guest')
users.value = res.users
}
if (Object.keys(res.settings).length) {
@@ -217,11 +217,11 @@ onMounted(async () => {
for (const key in res.settings) {
if (settingsMap[key]) {
// @ts-expect-error
settingsMap[key].value = res.settings[key];
settingsMap[key].value = res.settings[key]
}
}
}
});
})
</script>
<style lang="scss">
@@ -268,6 +268,10 @@ onMounted(async () => {
justify-content: space-between;
align-items: center;
padding-right: $smaller;
> button.adduser {
padding-right: $medium;
}
}
.h2 {
@@ -324,7 +328,7 @@ onMounted(async () => {
margin-top: 1.75rem !important;
&::before {
content: "";
content: '';
position: absolute;
top: -1rem;
left: 45%;

View File

@@ -1,158 +1,158 @@
<template>
<form
id="playlist-update-modal"
class="playlist-modal"
enctype="multipart/form-data"
autocomplete="off"
@submit.prevent="update_playlist"
>
<label for="name">Playlist name</label>
<input
id="modal-playlist-name-input"
v-model="pname"
type="search"
class="rounded-sm"
name="name"
spellcheck="false"
@keypress.enter.prevent="update_playlist"
/>
<form
id="playlist-update-modal"
class="playlist-modal"
enctype="multipart/form-data"
autocomplete="off"
@submit.prevent="update_playlist"
>
<label for="name">Playlist name</label>
<input
id="modal-playlist-name-input"
v-model="pname"
type="search"
class="rounded-sm"
name="name"
spellcheck="false"
@keypress.enter.prevent="update_playlist"
/>
<label for="image">Image</label>
<input
id="update-pl-image-upload"
ref="dropZoneRef"
type="file"
accept="image/*"
name="image"
style="display: none"
@change="handleUpload"
/>
<div id="upload" class="boxed rounded-sm">
<div class="clickable" tabindex="0" @click="selectFiles" @keydown.space.enter.stop="selectFiles">
<ImageIcon />
Click to {{ playlist.has_image ? "update" : "upload" }} cover image
</div>
<div
id="update-pl-img-preview"
class="image"
:style="{
backgroundImage: `url(${playlist.image})`,
}"
tabindex="0"
>
<div v-if="!image && playlist.has_image" class="delete-icon" @click="pStore.removeBanner()">
<DeleteIcon />
<label for="image">Image</label>
<input
id="update-pl-image-upload"
ref="dropZoneRef"
type="file"
accept="image/*"
name="image"
style="display: none"
@change="handleUpload"
/>
<div id="upload" class="boxed rounded-sm">
<div class="clickable" tabindex="0" @click="selectFiles" @keydown.space.enter.stop="selectFiles">
<ImageIcon />
Click to {{ playlist.has_image ? 'update' : 'upload' }} cover image
</div>
<div
id="update-pl-img-preview"
class="image"
:style="{
backgroundImage: `url(${playlist.image})`,
}"
tabindex="0"
>
<div v-if="!image && playlist.has_image" class="delete-icon" @click="pStore.removeBanner()">
<DeleteIcon />
</div>
</div>
</div>
<label v-if="playlist.has_image && !playlist.settings.square_img">Settings</label>
<div v-if="image || playlist.has_image" class="banner-settings rounded-sm">
<div>Show square cover image</div>
<Switch :state="playlist.settings.square_img || false" @click="pStore.toggleSquareImage" />
</div>
<div v-if="playlist.has_image && !playlist.settings.square_img" class="boxed banner-position-adjust rounded-sm">
<div class="t-center">Adjust image position • {{ pStore.info.settings.banner_pos }}%</div>
<div class="buttons">
<button @click.stop.prevent="pStore.minusBannerPos">
<ExpandSvg />
</button>
<button @click.stop.prevent="pStore.plusBannerPos">
<ExpandSvg />
</button>
</div>
</div>
</div>
</div>
<label v-if="playlist.has_image && !playlist.settings.square_img">Settings</label>
<div v-if="image || playlist.has_image" class="banner-settings rounded-sm">
<div>Show square cover image</div>
<Switch :state="playlist.settings.square_img || false" @click="pStore.toggleSquareImage" />
</div>
<div v-if="playlist.has_image && !playlist.settings.square_img" class="boxed banner-position-adjust rounded-sm">
<div class="t-center">Adjust image position • {{ pStore.info.settings.banner_pos }}%</div>
<div class="buttons">
<button @click.stop.prevent="pStore.minusBannerPos">
<ExpandSvg />
</button>
<button @click.stop.prevent="pStore.plusBannerPos">
<ExpandSvg />
</button>
</div>
</div>
<button type="submit">
{{ clicked ? "Saving" : "Update" }}
</button>
</form>
<button type="submit">
{{ clicked ? 'Saving' : 'Update' }}
</button>
</form>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { Ref, onMounted, ref } from "vue";
import { storeToRefs } from 'pinia'
import { Ref, onMounted, ref } from 'vue'
import { updatePlaylist } from "@/requests/playlists";
import usePStore from "@/stores/pages/playlist";
import { updatePlaylist } from '@/requests/playlists'
import usePStore from '@/stores/pages/playlist'
import DeleteIcon from "@/assets/icons/delete.svg";
import ExpandSvg from "@/assets/icons/expand.svg";
import ImageIcon from "@/assets/icons/image.svg";
import DeleteIcon from '@/assets/icons/delete.svg'
import ExpandSvg from '@/assets/icons/expand.svg'
import ImageIcon from '@/assets/icons/image.svg'
import Switch from "../SettingsView/Components/Switch.vue";
import Switch from '../SettingsView/Components/Switch.vue'
const pStore = usePStore();
const { info: playlist } = storeToRefs(pStore);
const pStore = usePStore()
const { info: playlist } = storeToRefs(pStore)
const pname = ref(playlist.value.name);
const image: Ref<any> = ref(null);
const pname = ref(playlist.value.name)
const image: Ref<any> = ref(null)
onMounted(() => {
(document.getElementById("modal-playlist-name-input") as HTMLElement).focus();
});
;(document.getElementById('modal-playlist-name-input') as HTMLElement).focus()
})
const emit = defineEmits<{
(e: "setTitle", title: string): void;
(e: "hideModal"): void;
}>();
(e: 'setTitle', title: string): void
(e: 'hideModal'): void
}>()
emit("setTitle", "Update Playlist");
emit('setTitle', 'Update Playlist')
function selectFiles() {
const input = document.getElementById("update-pl-image-upload") as HTMLInputElement;
input.click();
const input = document.getElementById('update-pl-image-upload') as HTMLInputElement
input.click()
}
function handleUpload() {
const input = document.getElementById("update-pl-image-upload") as HTMLInputElement;
const input = document.getElementById('update-pl-image-upload') as HTMLInputElement
if (input.files) {
handleFile(input.files[0]);
}
if (input.files) {
handleFile(input.files[0])
}
}
function handleFile(file: File) {
if (!file || !file.type.startsWith("image/")) {
return;
}
if (!file || !file.type.startsWith('image/')) {
return
}
const preview = document.getElementById("update-pl-img-preview");
const obj_url = URL.createObjectURL(file);
const preview = document.getElementById('update-pl-img-preview')
const obj_url = URL.createObjectURL(file)
if (preview) {
pStore.setImage(obj_url);
pStore.setInitialBannerPos();
}
if (preview) {
pStore.setImage(obj_url)
pStore.setInitialBannerPos()
}
image.value = file;
image.value = file
}
let clicked = ref(false);
let clicked = ref(false)
function update_playlist(e: Event) {
const form = document.getElementById("playlist-update-modal") as HTMLFormElement;
const formData = new FormData(form);
const form = document.getElementById('playlist-update-modal') as HTMLFormElement
const formData = new FormData(form)
const name = formData.get("name") as string;
const name = formData.get('name') as string
const nameChanged = name !== playlist.value.name;
const imgChanged = image.value !== undefined;
const nameChanged = name !== playlist.value.name
const imgChanged = image.value !== undefined
if (!nameChanged && !imgChanged) {
emit("hideModal");
return;
}
if (!nameChanged && !imgChanged) {
emit('hideModal')
return
}
clicked.value = true;
clicked.value = true
formData.append("image", image.value);
formData.append("settings", JSON.stringify(pStore.info.settings));
formData.append('image', image.value)
formData.append('settings', JSON.stringify(pStore.info.settings))
if (name && name.toString().trim() !== "") {
updatePlaylist(playlist.value.id, formData, pStore).then(() => {
emit("hideModal");
});
}
if (name && name.toString().trim() !== '') {
updatePlaylist(playlist.value.id, formData, pStore).then(() => {
emit('hideModal')
})
}
}
// Future TODO: Implement drag and drop for images here
@@ -160,137 +160,138 @@ function update_playlist(e: Event) {
<style lang="scss">
#playlist-update-modal {
input {
height: 3rem !important;
}
input {
height: 3rem !important;
}
}
.playlist-modal {
#modal-playlist-name-input {
margin-bottom: 1rem;
}
.boxed {
border: solid 2px $gray4;
color: $gray1;
place-items: center;
display: grid;
grid-template-columns: 1fr max-content;
}
.banner-settings {
font-weight: 500;
padding: 1rem;
background-color: $gray5;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
gap: $small;
margin: $small 0 1rem 0;
}
#upload {
width: 100%;
display: grid;
gap: $small;
border: none;
margin: $small 0 1rem 0;
svg {
height: 2rem;
#modal-playlist-name-input {
margin-bottom: 1rem;
}
#update-pl-img-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: $small;
object-fit: cover;
background-color: $gray4;
position: relative;
.boxed {
border: solid 2px $gray4;
color: $gray1;
place-items: center;
display: grid;
grid-template-columns: 1fr max-content;
}
.clickable {
font-weight: 500;
height: 100%;
width: 100%;
display: flex;
gap: $smaller;
place-items: center;
place-content: center;
border-radius: $small;
border: dashed 1px $gray4;
cursor: pointer;
padding: $medium;
svg {
transform: scale(0.75);
flex-shrink: 0;
}
.banner-settings {
font-weight: 500;
padding: 1rem;
background-color: $gray5;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
gap: $small;
margin: $small 0 1rem 0;
}
.delete-icon {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.521);
border-radius: $small;
transition: all 0.2s ease-out;
display: flex;
place-content: center;
place-items: center;
cursor: pointer;
svg {
transform: scale(1);
color: rgb(255, 255, 255);
}
&:hover {
background-color: $red;
#upload {
width: 100%;
display: grid;
gap: $small;
border: none;
margin: $small 0 1rem 0;
svg {
transform: scale(1.25);
transform-origin: center;
height: 2rem;
}
}
}
}
.banner-position-adjust {
gap: 1rem;
padding: $small 1rem;
margin-bottom: 1rem;
#update-pl-img-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: $small;
object-fit: cover;
background-color: $gray4;
position: relative;
}
.t-center {
position: relative;
font-weight: 500;
font-variant-numeric: tabular-nums;
.clickable {
font-weight: 500;
height: 100%;
width: 100%;
display: flex;
gap: $smaller;
place-items: center;
place-content: center;
border-radius: $small;
border: dashed 1px $gray4;
cursor: pointer;
padding: $medium;
svg {
transform: scale(0.75);
flex-shrink: 0;
}
}
.delete-icon {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.521);
border-radius: $small;
transition: all 0.2s ease-out;
display: flex;
place-content: center;
place-items: center;
cursor: pointer;
svg {
transform: scale(1);
color: rgb(255, 255, 255);
transition: transform 0.2s ease-out;
}
&:hover {
background-color: $red;
svg {
transform: scale(1.25);
transform-origin: center;
}
}
}
}
.buttons {
display: grid;
gap: $small;
.banner-position-adjust {
gap: 1rem;
padding: $small 1rem;
margin-bottom: 1rem;
button {
aspect-ratio: 1;
height: 2rem;
width: 2rem;
border: none;
background-color: $gray4;
padding: 0;
&:first-child {
transform: rotate(-90deg);
.t-center {
position: relative;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
&:last-child {
transform: rotate(90deg);
}
.buttons {
display: grid;
gap: $small;
&:hover {
background-color: $blue;
button {
aspect-ratio: 1;
height: 2rem;
width: 2rem;
border: none;
background-color: $gray4;
padding: 0;
&:first-child {
transform: rotate(-90deg);
}
&:last-child {
transform: rotate(90deg);
}
&:hover {
background-color: $blue;
}
}
}
}
}
}
}
</style>

View File

@@ -1,49 +1,56 @@
<template>
<div id="back-forward" class="">
<button class="back" @click="$router.back()">
<ArrowSvg />
</button>
<button class="forward" @click="$router.forward()">
<ArrowSvg />
</button>
</div>
<div id="back-forward" class="">
<button class="back" @click="$router.back()">
<ArrowSvg />
</button>
<button class="forward" @click="$router.forward()">
<ArrowSvg />
</button>
</div>
</template>
<script setup lang="ts">
import ArrowSvg from "../../assets/icons/right-arrow.svg";
import ArrowSvg from '../../assets/icons/right-arrow.svg'
</script>
<style lang="scss">
#back-forward {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-right: 1rem;
border-right: 1px solid $gray5;
height: max-content;
display: grid;
grid-template-columns: 1fr 1fr;
gap: $medium;
padding-right: 1rem;
border-right: 1px solid $gray5;
height: max-content;
& > * {
width: 2.25rem;
height: 2.25rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
& > * {
width: 2.15rem;
height: 2.15rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
background-color: transparent;
border: 1px solid $gray5;
svg {
transform: scale(1.12);
transition: transform 0.2s ease;
&:hover {
background-color: $gray4;
border-color: $gray4;
}
&:active {
transform: scale(0.88);
}
svg {
transform: scale(0.96);
transition: transform 0.2s ease;
}
&:active > svg {
transform: scale(0.76);
}
}
}
.back {
transform: rotate(180deg);
}
.back {
transform: rotate(180deg);
}
}
</style>

View File

@@ -1,148 +1,149 @@
<template>
<div class="sidenav noSelect">
<div class="sidenav_header">
<a @click="closeSidenav" class="sidenav_logo" href="#">
<div class="art"><LogoSvg /></div>
<div class="title">Swing Music</div>
</a>
<div class="sidenav noSelect">
<div class="sidenav_header">
<a @click="closeSidenav" class="sidenav_logo" href="#">
<div class="art"><LogoSvg /></div>
<div class="title">Swing Music</div>
</a>
</div>
<div class="sidenav_content scrollable">
<RouterLink
v-for="link in topnavitems"
:key="link.name"
class="link"
:to="{ name: link.route_name, params: link.params }"
:class="{ active: $route.name === link.route_name }"
@click="closeSidenav"
>
<component :is="link.icon" />
<!-- Render the icon as a Vue component -->
<span>{{ link.name }}</span>
</RouterLink>
</div>
<div class="sidenav_footer">Swing Music - v</div>
</div>
<div class="sidenav_content scrollable">
<RouterLink
v-for="link in topnavitems"
:key="link.name"
class="link"
:to="{ name: link.route_name, params: link.params }"
:class="{ active: $route.name === link.route_name }"
@click="closeSidenav"
>
<component :is="link.icon" />
<!-- Render the icon as a Vue component -->
<span>{{ link.name }}</span>
</RouterLink>
</div>
<div class="sidenav_footer">Swing Music - v</div>
</div>
</template>
<script setup lang="ts">
import LogoSvg from "@/assets/icons/logos/logo-fill.light.svg";
import { topnavitems } from "../LeftSidebar/navitems";
import LogoSvg from '@/assets/icons/logos/logo-fill.light.svg'
import { topnavitems } from '../LeftSidebar/navitems'
const emit = defineEmits(["close"]);
const emit = defineEmits(['close'])
function closeSidenav() {
emit("close");
emit('close')
}
</script>
<style lang="scss">
.sidenav_toggle {
display: none;
display: none;
@include allPhones {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 6px;
width: 28px;
height: 28px;
cursor: pointer;
@include allPhones {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 6px;
width: 32px;
height: 32px;
padding: 2px;
cursor: pointer;
> .bar {
height: 2px;
width: calc(100% - 14px);
border-radius: 1rem;
background-color: $white;
opacity: 0.75;
transition: color 0.2s ease-out, transform 0.2s ease-out;
> .bar {
height: 2px;
width: calc(100% - 14px);
border-radius: 1rem;
background-color: $white;
opacity: 0.75;
transition: color 0.2s ease-out, transform 0.2s ease-out;
}
&:hover {
> .bar {
background-color: #ffffff;
}
}
}
&:hover {
> .bar {
background-color: #ffffff;
}
}
}
}
.sidenav {
display: none;
display: none;
@include allPhones {
position: fixed;
top: 0;
left: 0;
z-index: 1002;
width: 240px;
height: 100%;
display: flex;
flex-direction: column;
background-color: $body;
transform: translateX(-240px);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
.sidenav_header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $large 24px;
box-sizing: border-box;
.sidenav_logo {
@include allPhones {
position: fixed;
top: 0;
left: 0;
z-index: 1002;
width: 240px;
height: 100%;
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
background-color: $body;
transform: translateX(-240px);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
.title {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
.sidenav_header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $large 24px;
box-sizing: border-box;
.sidenav_logo {
display: flex;
align-items: center;
gap: 1rem;
.title {
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
}
}
}
.sidenav_content {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
margin-right: 2px;
overflow: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
.link {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
text-transform: capitalize;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
margin: $smaller $medium;
padding: $small $medium;
cursor: pointer;
}
svg {
height: 1.5rem;
}
}
.sidenav_footer {
font-size: $medium;
margin: $large auto;
opacity: 0.5;
}
}
}
.sidenav_content {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
margin-right: 2px;
overflow: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
.link {
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
text-transform: capitalize;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
margin: $smaller $medium;
padding: $small $medium;
cursor: pointer;
}
svg {
height: 1.5rem;
}
}
.sidenav_footer {
font-size: $medium;
margin: $large auto;
opacity: 0.5;
}
}
}
.sidenav.active {
transform: translateX(0);
transform: translateX(0);
}
</style>

View File

@@ -1,116 +1,131 @@
<template>
<div class="profiledrop rounded-sm pad-sm shadow-lg">
<div class="info item">
<div class="username ellip2">
Hi {{ auth.user.username }}
</div>
<div class="profiledrop rounded-md pad-sm shadow-lg noSelect">
<div class="info item">
<div class="username ellip2">Hi {{ auth.user.firstname || auth.user.username }}</div>
</div>
<div class="separator"></div>
<div class="item scan" @click="triggerScan">
<div class="label">Quick scan</div>
<ReloadSvg />
</div>
<div class="item" @click="modal.showSettingsModal">
<div class="label">Settings</div>
<SettingsSvg />
</div>
<div class="separator"></div>
<div class="item critical logout" @click="auth.logout">
<div class="label">Log out</div>
<LogoutSvg />
</div>
</div>
<div class="separator"></div>
<div class="item scan" @click="triggerScan">
<div class="label">Quick scan</div>
<ReloadSvg />
</div>
<div class="item" @click="modal.showSettingsModal">
<div class="label">Settings</div>
<SettingsSvg />
</div>
<div class="separator"></div>
<div class="item critical logout" @click="auth.logout">
<div class="label">Log out</div>
<LogoutSvg />
</div>
</div>
</template>
<script setup lang="ts">
import useAuth from "@/stores/auth";
import useModal from "@/stores/modal";
import useAuth from '@/stores/auth'
import useModal from '@/stores/modal'
import SettingsSvg from "@/assets/icons/settings.svg";
import LogoutSvg from "@/assets/icons/logout.svg";
import ReloadSvg from "@/assets/icons/reload.svg";
import { triggerScan } from "@/requests/settings/rootdirs";
import LogoutSvg from '@/assets/icons/logout.svg'
import ReloadSvg from '@/assets/icons/reload.svg'
import SettingsSvg from '@/assets/icons/settings.svg'
import { triggerScan } from '@/requests/settings/rootdirs'
const auth = useAuth();
const modal = useModal();
const auth = useAuth()
const modal = useModal()
</script>
<style lang="scss">
.profiledrop {
position: absolute;
z-index: 10;
top: 2.25rem;
right: 0;
width: 10rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
flex-direction: column;
border: solid 1px $gray5;
background-color: $gray;
position: absolute;
.separator {
height: 1px;
background-color: $gray3;
padding: 0;
}
.item {
z-index: 9999;
top: 2.25rem;
right: 0;
width: 10.25rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
align-items: center;
justify-content: space-between;
gap: $smaller;
padding: $small $medium;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray4;
}
svg {
height: 1.5rem;
}
}
.item.logout svg, .scan svg {
// INFO: Though the icons are 1.5rem, it looks larger than the rest
// So, we reduce the size a bit.
height: 1.25rem;
}
.logout svg {
margin-right: 1px;
}
.scan svg {
margin-right: 3px;
}
.info {
flex-direction: column;
align-items: baseline;
gap: $smallest;
cursor: auto;
padding: 0.25rem 0.75rem;
border: solid 1px $gray5;
background-color: $gray;
&:hover {
background-color: transparent;
.separator {
height: 1px;
background-color: $gray3;
padding: 0;
}
.username {
font-weight: 500;
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $smaller;
padding: $small $medium;
padding-right: $small;
max-height: 36px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease-out, opacity 0.2s ease-out, box-shadow 0.2s ease-out;
&:hover {
background-color: $gray4;
}
&:active {
opacity: 0.3;
}
svg {
display: block;
height: 1.5rem;
}
}
}
.critical {
color: $red;
}
.item.scan {
margin-bottom: $smaller;
}
.critical:hover {
background-color:transparent;
outline: solid 1px;
}
.item.logout svg,
.scan svg {
// INFO: Though the icons are 1.5rem, it looks larger than the rest
// So, we reduce the size a bit.
height: 1.25rem;
}
.logout svg {
margin-right: 1px;
}
.scan svg {
margin-right: 3px;
}
.info {
gap: $small;
cursor: auto;
padding: $smaller $medium;
&:hover {
background-color: transparent;
}
> .username {
font-weight: 500;
}
}
.info.item {
max-height: unset;
opacity: unset;
pointer-events: none;
}
.critical {
color: $red;
}
.critical:hover {
background-color: transparent;
box-shadow: 0 0 0 1px $red;
}
}
</style>

View File

@@ -43,8 +43,8 @@ function navigate(path: string) {
}
interface SortItem {
key: string;
title: string;
key: string
title: string
}
const items: SortItem[] = [
@@ -111,7 +111,7 @@ const current = computed(() => {
}
.fname {
background-color: $gray4;
background-color: $gray5;
border-radius: $small;
height: 2.188rem;
display: flex;

View File

@@ -5,6 +5,8 @@
params: { albumhash: album.albumhash },
}"
class="album-card"
@contextmenu.prevent="showMenu"
:class="{ 'context-menu-open': contextMenuFlag }"
>
<div class="with-img rounded-sm no-scroll">
<div
@@ -23,7 +25,7 @@
</div>
<div>
<div v-if="album.help_text" class="rhelp album">
<span class="help">{{ album.help_text }}</span>
<span class="help" :class="{ keep: !album.time }">{{ album.help_text }}</span>
<span class="time">{{ album.time }}</span>
</div>
<h4 v-tooltip class="title ellip">
@@ -56,7 +58,7 @@
<script setup lang="ts">
import { Routes } from '@/router'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Album } from '../../interfaces'
@@ -66,9 +68,11 @@ import { playSources } from '@/enums'
import useAlbumStore from '@/stores/pages/album'
import { paths } from '../../config'
import MasterFlag from './MasterFlag.vue'
import { showAlbumContextMenu } from '@/helpers/contextMenuHandler'
const imguri = paths.images.thumb.medium
const route = useRoute()
const contextMenuFlag = ref(false)
const imguri = paths.images.thumb.medium
const props = defineProps<{
album: Album
@@ -94,6 +98,10 @@ const artists = computed(() => {
return albumartists
})
function showMenu(e: MouseEvent) {
showAlbumContextMenu(e, contextMenuFlag, props.album)
}
</script>
<style lang="scss">
@@ -105,6 +113,10 @@ const artists = computed(() => {
height: max-content;
transition: background-color 0.2s ease-out;
&.context-menu-open {
background-color: $gray5;
}
.with-img {
position: relative;
@@ -121,6 +133,7 @@ const artists = computed(() => {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.25s ease;
}
&:hover {
@@ -129,10 +142,6 @@ const artists = computed(() => {
opacity: 1;
}
img {
border-radius: 0 0 $medium $medium;
}
.gradient {
opacity: 1;
}

View File

@@ -1,114 +1,128 @@
<template>
<RouterLink
:to="{
name: Routes.artist,
params: {
hash: artist.artisthash,
},
}"
class="artist-card"
>
<div class="image circular">
<img class="artist-image circular" :src="imguri + artist.image" />
<div
class="overlay circular"
:style="{
background: `linear-gradient(to top, ${artist.color} 20%, transparent)`,
<RouterLink
:to="{
name: Routes.artist,
params: {
hash: artist.artisthash,
},
}"
></div>
<PlayBtn :artisthash="artist.artisthash" :artistname="artist.name" :source="playSources.artist" />
</div>
<div v-if="artist.help_text" class="rhelp t-center">
<span class="help">{{ artist.help_text }}</span>
<span class="time">{{ artist.time }}</span>
</div>
<div class="artist-name t-center">
{{ artist.name }}
</div>
<div v-if="artist.help_text && artist.trackcount" class="racount t-center">
{{ artist.trackcount }} Track{{ artist.trackcount == 1 ? "" : "s" }}
</div>
</RouterLink>
class="artist-card"
@contextmenu.prevent="showContextMenu"
:class="{ 'context-menu-open': contextMenuFlag }"
>
<div class="image circular">
<img class="artist-image circular" :src="imguri + artist.image" />
<div
class="overlay circular"
:style="{
background: `linear-gradient(to top, ${artist.color} 20%, transparent)`,
}"
></div>
<PlayBtn :artisthash="artist.artisthash" :artistname="artist.name" :source="playSources.artist" />
</div>
<div v-if="artist.help_text" class="rhelp t-center">
<span class="help" :class="{ keep: !artist.time }">{{ artist.help_text }}</span>
<span class="time">{{ artist.time }}</span>
</div>
<div class="artist-name t-center">
{{ artist.name }}
</div>
<div v-if="artist.help_text && artist.trackcount" class="racount t-center">
{{ artist.trackcount }} Track{{ artist.trackcount == 1 ? '' : 's' }}
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import { Artist } from "@/interfaces";
import { Routes } from "@/router";
import { paths } from '@/config'
import { Artist } from '@/interfaces'
import { Routes } from '@/router'
import { playSources } from "@/enums";
import PlayBtn from "./PlayBtn.vue";
import { playSources } from '@/enums'
import PlayBtn from './PlayBtn.vue'
import { ref } from 'vue'
import { showArtistContextMenu } from '@/helpers/contextMenuHandler'
const imguri = paths.images.artist.medium;
const imguri = paths.images.artist.medium
const contextMenuFlag = ref(false)
defineProps<{
artist: Artist;
}>();
const props = defineProps<{
artist: Artist
}>()
const showContextMenu = (e: MouseEvent) => {
showArtistContextMenu(e, contextMenuFlag, props.artist.artisthash, props.artist.name)
}
</script>
<style lang="scss">
.artist-card {
overflow: hidden;
position: relative;
border-radius: $medium;
justify-content: center;
padding: 1.2rem 1rem !important;
font-size: 0.95rem;
font-weight: 700;
height: max-content;
transition: background-color 0.2s ease-out;
.image {
overflow: hidden;
position: relative;
.overlay {
position: absolute;
width: 100%;
height: calc(100% - $small + 1px);
top: 0;
opacity: 0;
border-radius: $medium;
justify-content: center;
padding: 1.2rem 1rem !important;
font-size: 0.95rem;
font-weight: 700;
height: max-content;
transition: background-color 0.2s ease-out;
&.context-menu-open {
background-color: $gray5;
}
}
$btnwidth: 4rem;
.image {
position: relative;
.play-btn {
opacity: 0;
position: absolute;
width: 4rem;
bottom: 0;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
.overlay {
position: absolute;
width: 100%;
height: calc(100% - $small + 1px);
top: 0;
opacity: 0;
transition: opacity 0.25s ease;
}
}
&:hover {
background-color: $gray5;
$btnwidth: 4rem;
.play-btn {
opacity: 1;
transform: translateY(-1.25rem);
opacity: 0;
position: absolute;
width: 4rem;
bottom: 0;
left: calc(50% - ($btnwidth / 2));
transition: all 0.25s;
}
.overlay {
opacity: 1;
&:hover {
background-color: $gray5;
.play-btn {
opacity: 1;
transform: translateY(-1.25rem);
}
.overlay {
opacity: 1;
}
}
}
.artist-image {
width: 100%;
transition: all 0.5s ease-in-out;
object-fit: cover;
margin-bottom: $smaller;
}
.artist-image {
width: 100%;
transition: all 0.5s ease-in-out;
object-fit: cover;
margin-bottom: $smaller;
}
.artist-name {
word-break: break-word;
}
.artist-name {
word-break: break-word;
}
.racount {
font-size: 12px;
color: #ffffffbf;
}
.racount {
font-size: 12px;
color: #ffffffbf;
}
}
</style>

View File

@@ -1,32 +1,66 @@
<template>
<div v-if="type == 'album'" class="cardlistrow">
<AlbumCard v-for="item in items" :key="item.albumhash" class="hlistitem" :album="(item as Album)" />
</div>
<div v-else-if="type == 'artist'" class="cardlistrow">
<ArtistCard v-for="item in items" :key="item.artisthash" class="hlistitem" :artist="(item as Artist)" />
</div>
<div class="cardlistrow">
<component v-for="item in items" :key="item.key" :is="item.component" v-bind="item.props" />
</div>
</template>
<script setup lang="ts">
import { Album, Artist } from "@/interfaces";
import AlbumCard from "./AlbumCard.vue";
import ArtistCard from "./ArtistCard.vue";
import { Album, Artist, Mix } from '@/interfaces'
import AlbumCard from './AlbumCard.vue'
import ArtistCard from './ArtistCard.vue'
import MixCard from '../Mixes/MixCard.vue'
import { computed } from 'vue'
defineProps<{
type: "album" | "artist";
items: Album[] | Artist[];
}>();
const props = defineProps<{
items: Album[] | Artist[] | Mix[]
}>()
const items = computed(() => {
return props.items.map((item: any) => {
const i = {
component: <any>null,
props: {},
key: '',
}
switch (item['type']) {
case 'album':
i.component = AlbumCard
i.key = item.albumhash
i.props = {
album: item,
}
break
case 'artist':
i.component = ArtistCard
i.key = item.artisthash
i.props = {
artist: item,
}
break
case 'mix':
i.component = MixCard
i.key = item.sourcehash
i.props = {
mix: item,
}
break
}
return i
})
})
</script>
<style lang="scss">
.cardlistrow {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
padding-bottom: 2rem;
z-index: -1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax($cardwidth, 1fr));
padding-bottom: 2rem;
z-index: -1;
@include mediumPhones {
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
}
@include mediumPhones {
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
}
}
</style>

View File

@@ -2,18 +2,24 @@
<div class="cardscroller">
<div class="rinfo">
<div class="rtitle">
<b>{{ title }}</b>
<b>
<RouterLink :to="route || ''">
{{ title }}
</RouterLink>
</b>
<SeeAll v-if="route && itemlist.length >= maxAbumCards" :route="route" :text="seeAllText" />
</div>
<div v-if="description" class="rdesc">
{{ description }}
<RouterLink :to="route || ''">
{{ description }}
</RouterLink>
</div>
</div>
<div class="recentitems">
<component
:is="getComponent(i.type)"
v-for="(i, index) in itemlist.slice(0, maxAbumCards)"
:key="index"
:key="i"
class="hlistitem"
v-bind="getProps(i)"
@playThis="() => $emit('playThis', index)"
@@ -35,6 +41,7 @@ import CardContent from './CardContent.vue'
import FavoritesCard from './FavoritesCard.vue'
import FolderCard from './FolderCard.vue'
import TrackCard from './TrackCard.vue'
import MixCard from '@/components/Mixes/MixCard.vue'
const props = defineProps<{
title: string
@@ -85,6 +92,8 @@ function getComponent(type: string) {
return PlaylistCard
case 'favorite_tracks':
return FavoritesCard
case 'mix':
return MixCard
}
}
@@ -122,6 +131,10 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
return {
item: item.item,
}
case 'mix':
return {
mix: item.item,
}
}
}
</script>
@@ -183,6 +196,10 @@ function getProps(item: { type: string; item?: any; with_helptext?: boolean }) {
display: none;
}
.keep {
display: block !important;
}
// INFO: Set the time to display block on hover
.rhelp .time {
display: block;

View File

@@ -5,10 +5,14 @@
class="selected"
:class="{ showDropDown }"
@click.prevent="handleOpener"
:title="`sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase()"
:title="
reverse !== 'hide'
? `sort by: ${current.title} ${reverse ? 'Descending' : 'Ascending'}`.toUpperCase()
: undefined
"
>
<span class="ellip">{{ current.title }}</span>
<ArrowSvg :class="{ reverse }" />
<ArrowSvg :class="{ reverse }" class="dropdown-arrow" v-if="reverse !== 'hide'" />
</button>
<div v-if="showDropDown" ref="dropOptionsRef" class="options rounded no-scroll shadow-lg">
<div
@@ -25,8 +29,8 @@
</div>
</template>
<script setup lang="ts">
import { Ref, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { Ref, ref } from 'vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
@@ -42,7 +46,7 @@ defineProps<{
items: Item[]
current: Item
component_key: string
reverse: boolean
reverse: boolean | 'hide'
}>()
const emit = defineEmits<{
@@ -68,6 +72,12 @@ onClickOutside(dropOptionsRef, e => {
<style lang="scss">
.smdropdown {
z-index: 1000;
.dropdown-arrow {
width: 100%;
aspect-ratio: 1;
}
.selected {
width: 100%;
display: grid;
@@ -111,8 +121,11 @@ onClickOutside(dropOptionsRef, e => {
}
.option {
font-weight: 500;
cursor: pointer;
padding: $small;
border-radius: $small;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray5;

View File

@@ -1,31 +1,50 @@
<template>
<div class="generichead">
<div class="left">
<h1 class="title"><slot name="name"></slot></h1>
<div class="desc">
<slot name="description"></slot>
<div class="before">
<div class="left">
<h1 class="title"><slot name="name"></slot></h1>
<div class="desc">
<slot name="description"></slot>
</div>
</div>
<div class="right">
<slot name="right"></slot>
</div>
</div>
<div class="right">
<slot name="right"></slot>
<div class="after">
<slot name="after"></slot>
</div>
</div>
</template>
<style lang="scss">
.generichead {
padding: 1rem 0 1rem $medium;
height: max-content;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
max-width: 100%;
overflow: hidden;
.left {
max-width: 100%;
padding: 0 0 1rem $medium;
height: max-content;
align-items: center;
overflow: hidden;
}
max-width: 100%;
.before {
display: grid;
grid-template-columns: 1fr max-content;
}
.right {
display: flex;
align-items: center;
height: 100%;
}
.after {
margin-top: 2rem;
margin-left: -$medium;
}
.left {
max-width: 100%;
overflow: hidden;
}
h1 {
width: max-content;
@@ -33,23 +52,23 @@
font-size: 3.25rem;
}
.title {
font-weight: 700;
margin-left: -1px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
font-weight: 700;
margin-left: -1px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.desc {
font-size: 1rem;
line-height: 1.5;
font-weight: 500;
}
.desc {
font-size: 1rem;
line-height: 1.5;
font-weight: 500;
}
@include mediumPhones {
gap: 1rem;
grid-template-columns: repeat(auto-fill, 100%);
}
@include mediumPhones {
gap: 1rem;
grid-template-columns: repeat(auto-fill, 100%);
}
}
</style>

View File

@@ -26,23 +26,23 @@
</template>
<script setup lang="ts">
import { Motion } from "motion/vue";
import { Motion } from 'motion/vue'
import HeartFillSvg from "@/assets/icons/heart.fill.svg";
import HeartSvg from "@/assets/icons/heart.svg";
import HeartFillSvg from '@/assets/icons/heart.fill.svg'
import HeartSvg from '@/assets/icons/heart.svg'
import { getTextColor } from "@/utils/colortools/shift";
import { getTextColor } from '@/utils/colortools/shift'
defineProps<{
state: Boolean | undefined;
no_emit?: Boolean;
color?: string;
}>();
state: Boolean | undefined
no_emit?: Boolean
color?: string
}>()
defineEmits<{
// eslint-disable-next-line no-unused-vars
(event: "handleFav"): void;
}>();
(event: 'handleFav'): void
}>()
</script>
<style lang="scss">
@@ -62,7 +62,8 @@ $bg: rgb(255, 255, 255);
transform: scale(1);
svg {
height: 1.5rem;
height: 1.75rem;
width: 1.75rem;
display: block;
}
}

View File

@@ -1,8 +1,5 @@
<template>
<div
class="loginuser rounded-sm"
:class="{ selected }"
>
<div class="loginuser rounded-sm" :class="{ selected }">
<Avatar :name="user.username" />
<div class="username">
{{ (selected ? `Hi ` : '') + user.username }}
@@ -32,6 +29,10 @@ defineProps<{
&.selected {
pointer-events: none;
}
> .username {
font-weight: 500;
}
}
.loginuser:hover {

View File

@@ -28,7 +28,7 @@ defineProps<{
font-weight: 600;
margin-left: $smaller;
padding: 2px 5px;
border-radius: 5px;
border-radius: 4px;
opacity: 0.75;
text-transform: uppercase;
}

View File

@@ -13,7 +13,7 @@ import {
playFromFolderCard,
playFromPlaylist,
} from "@/helpers/usePlayFrom";
import { Track } from "@/interfaces";
import { Playlist, Track } from "@/interfaces";
import PlaySvg from "@/assets/icons/play.svg";
import useQueue from "@/stores/queue";
@@ -27,6 +27,7 @@ const props = defineProps<{
artisthash?: string;
artistname?: string;
folderpath?: string;
playlist?: string;
track?: Track;
}>();
@@ -61,6 +62,9 @@ function handlePlay() {
case playSources.favorite:
playFromFavorites(props.track);
break;
case playSources.playlist:
playFromPlaylist(props.playlist as string);
break;
default:
break;

View File

@@ -1,42 +1,43 @@
<template>
<button
v-wave
class="playbtnrect shadow-sm circular btn-active"
:style="{
backgroundColor: bg_color ? bg_color : '',
borderColor: bg_color ? bg_color : '',
color: bg_color ? getShift(bg_color, [100, 100]) : '',
}"
@click="playFrom(source)"
>
<playBtnSvg />
<div class="text">Play</div>
</button>
<button
v-wave
class="playbtnrect shadow-sm circular btn-active"
:style="{
backgroundColor: bg_color ? bg_color : '',
borderColor: bg_color ? bg_color : '',
color: bg_color ? getShift(bg_color, [100, 100]) : '',
}"
@click="playFrom(source)"
>
<playBtnSvg />
<div class="text">Play</div>
</button>
</template>
<script setup lang="ts">
import { playSources } from "@/enums";
import { getShift } from "@/utils/colortools/shift";
import { playSources } from '@/enums'
import { getShift } from '@/utils/colortools/shift'
import { playFrom } from "@/helpers/usePlayFrom";
import playBtnSvg from "@/assets/icons/play.svg";
import playBtnSvg from '@/assets/icons/play.svg'
import { playFrom } from '@/helpers/usePlayFrom'
defineProps<{
source: playSources;
bg_color?: string;
}>();
source: playSources
bg_color?: string
}>()
</script>
<style lang="scss">
.playbtnrect {
width: 6rem;
display: flex;
align-items: center;
justify-content: center;
color: $white;
width: 6rem;
display: flex;
align-items: center;
justify-content: center;
color: $white;
padding-right: 1rem;
svg {
height: 1.75rem;
}
svg {
height: 1.75rem;
}
}
</style>

View File

@@ -1,160 +1,184 @@
<template>
<div
class="songlist-item rounded-sm"
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
@dblclick.prevent="emitUpdate"
@contextmenu.prevent="showMenu"
>
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
<TrackTitle :track="track" :is_current="isCurrent()" :is_current_playing="isCurrentPlaying()" @play="emitUpdate" />
<div class="song-artists">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" />
</div>
<div
class="songlist-item rounded-sm"
:class="[{ current: isCurrent() }, { contexton: context_menu_showing }]"
@dblclick.prevent="emitUpdate"
@contextmenu.prevent="showMenu"
>
<TrackIndex v-if="!isSmall" :index="index" :is_fav="is_fav" @add-to-fav="addToFav(track.trackhash)" />
<TrackTitle
:track="track"
:is_current="isCurrent()"
:is_current_playing="isCurrentPlaying()"
@play="emitUpdate"
/>
<div class="song-artists">
<ArtistName :artists="track.artists" :albumartists="track.albumartists" />
</div>
<TrackAlbum
:album="track.album || 'Unknown'"
:albumhash="track.albumhash || ''"
:hide_album="hide_album || false"
/>
<TrackDuration :duration="track.duration || 0" @showMenu="showMenu" />
</div>
<TrackAlbum
:album="track.album || 'Unknown'"
:albumhash="track.albumhash || ''"
:hide_album="hide_album || false"
/>
<TrackDuration
:duration="track.duration || 0"
@showMenu="showMenu"
:help_text="track.help_text"
:is_fav="is_fav"
:showFavIcon="!isFavoritesPage"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { dropSources, favType } from "@/enums";
import { showTrackContextMenu as showContext } from "@/helpers/contextMenuHandler";
import favoriteHandler from "@/helpers/favoriteHandler";
import { Track } from "@/interfaces";
import { isSmall } from "@/stores/content-width";
import useQueueStore from "@/stores/queue";
import { dropSources, favType } from '@/enums'
import { showTrackContextMenu as showContext } from '@/helpers/contextMenuHandler'
import favoriteHandler from '@/helpers/favoriteHandler'
import { Track } from '@/interfaces'
import { isSmall } from '@/stores/content-width'
import useQueueStore from '@/stores/queue'
import ArtistName from "./ArtistName.vue";
import TrackAlbum from "./SongItem/TrackAlbum.vue";
import TrackDuration from "./SongItem/TrackDuration.vue";
import TrackIndex from "./SongItem/TrackIndex.vue";
import TrackTitle from "./SongItem/TrackTitle.vue";
import ArtistName from './ArtistName.vue'
import TrackAlbum from './SongItem/TrackAlbum.vue'
import TrackDuration from './SongItem/TrackDuration.vue'
import TrackIndex from './SongItem/TrackIndex.vue'
import TrackTitle from './SongItem/TrackTitle.vue'
const context_menu_showing = ref(false);
const context_menu_showing = ref(false)
const queue = useQueueStore();
const queue = useQueueStore()
const props = defineProps<{
track: Track;
index: number | string;
hide_album?: boolean;
is_queue_track?: boolean;
droppable?: boolean;
is_last?: boolean;
source: dropSources;
}>();
track: Track
index: number | string
hide_album?: boolean
is_queue_track?: boolean
droppable?: boolean
is_last?: boolean
source: dropSources
}>()
const is_fav = ref(props.track.is_favorite || false);
const is_fav = ref(props.track.is_favorite || false)
const emit = defineEmits<{
(e: "playThis"): void;
(e: "trackDropped", source: dropSources, track: Track, newIndex: number, oldIndex: number): void;
}>();
(e: 'playThis'): void
(e: 'trackDropped', source: dropSources, track: Track, newIndex: number, oldIndex: number): void
}>()
function emitUpdate() {
emit("playThis");
emit('playThis')
}
function showMenu(e: MouseEvent) {
showContext(e, props.track, context_menu_showing);
showContext(e, props.track, context_menu_showing)
}
function isCurrent() {
if (props.is_queue_track) {
return queue.currentindex == parseInt(props.index as string) - 1;
}
if (props.is_queue_track) {
return queue.currentindex == parseInt(props.index as string) - 1
}
return queue.currenttrackhash == props.track.trackhash;
return queue.currenttrackhash == props.track.trackhash
}
function isCurrentPlaying() {
return isCurrent() && queue.playing;
return isCurrent() && queue.playing
}
function addToFav(trackhash: string) {
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
);
favoriteHandler(
is_fav.value,
favType.track,
trackhash,
() => (is_fav.value = true),
() => (is_fav.value = false)
)
}
const stopWatcher = watch(
() => props.track.trackhash,
() => {
is_fav.value = props.track.is_favorite;
}
);
() => props.track.trackhash,
() => {
is_fav.value = props.track.is_favorite
}
)
onBeforeUnmount(() => {
stopWatcher();
});
stopWatcher()
})
const route = useRoute()
const isFavoritesPage = route.path.startsWith('/favorites')
</script>
<style lang="scss">
// NOTE: CSS for responsiveness is at app-grid.scss
.songlist-item {
display: grid;
grid-template-columns: 1.75rem 1.25fr 1fr 1fr 5.5rem;
align-items: center;
justify-content: flex-start;
gap: 1rem;
font-weight: 500;
line-height: 1.2;
height: $song-item-height;
user-select: none;
padding-left: $small;
position: relative;
transition: background-color 0.2s ease-out;
display: grid;
grid-template-columns: 1.75rem 1.25fr 1fr 1fr 7.5rem;
align-items: center;
justify-content: flex-start;
gap: 1rem;
font-weight: 500;
line-height: 1.2;
height: $song-item-height;
user-select: none;
padding-left: $small;
position: relative;
transition: background-color 0.2s ease-out;
&:hover {
background-color: $gray5;
&:hover {
background-color: $gray5;
.index {
.text {
transition-delay: 400ms;
transform: translateX(0);
opacity: 0;
}
.heart-icon {
transition-delay: 400ms;
transform: translateX(0);
opacity: 1;
visibility: visible;
}
}
.song-duration.has_help_text {
opacity: 0;
}
// INFO: Show help text on hover
.song-duration.help-text {
opacity: 1;
}
}
.index {
.text {
transition-delay: 400ms;
overflow: unset !important;
transform: translateX(0);
opacity: 0;
}
.heart-icon {
transition-delay: 400ms;
transform: translateX(0);
opacity: 1;
visibility: visible;
}
.heart-icon {
opacity: 0;
visibility: hidden;
}
}
}
.index {
overflow: unset !important;
.heart-icon {
opacity: 0;
visibility: hidden;
.song-artists {
width: fit-content;
max-width: calc(100% - 10px);
}
}
.song-artists {
width: fit-content;
max-width: 100%;
}
}
.songlist-item.current {
background-color: $gray;
background-color: $gray;
}
.songlist-item.contexton {
background-color: $gray4 !important;
background-color: $gray4 !important;
}
</style>

View File

@@ -1,35 +1,36 @@
<template>
<router-link
v-if="!hide_album"
v-tooltip
class="song-album ellip"
:to="{
name: 'AlbumView',
params: {
albumhash: albumhash || 'Unknown',
},
}"
>
{{ album }}
</router-link>
<router-link
v-if="!hide_album"
v-tooltip
class="song-album ellip"
:to="{
name: 'AlbumView',
params: {
albumhash: albumhash || 'Unknown',
},
}"
>
{{ album }}
</router-link>
</template>
<script setup lang="ts">
defineProps<{
hide_album: boolean;
albumhash: string;
album: string;
}>();
hide_album: boolean
albumhash: string
album: string
}>()
</script>
<style lang="scss">
.song-album {
max-width: max-content;
color: inherit;
cursor: pointer !important;
max-width: max-content;
max-width: calc(100% - 10px);
color: inherit;
cursor: pointer !important;
&:hover {
text-decoration: underline;
}
&:hover {
text-decoration: underline;
}
}
</style>

View File

@@ -1,63 +1,119 @@
<template>
<div class="options-and-duration">
<div class="song-duration">{{ formatSeconds(duration) }}</div>
<div class="options-icon circular" @click.stop="$emit('showMenu', $event)" @dblclick.stop="() => {}">
<OptionSvg />
<div class="options-and-duration">
<div v-if="is_fav && showFavIcon !== false" class="heart-icon is-favorited">
<HeartSvg :state="is_fav" :no_emit="true" />
</div>
<div class="song-duration" :class="{ has_help_text: help_text }">{{ formatSeconds(duration) }}</div>
<div class="song-duration help-text" v-if="help_text">
{{ help_text }}
</div>
<div class="options-icon circular" @click.stop="$emit('showMenu', $event)" @dblclick.stop="() => {}">
<OptionSvg />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import OptionSvg from "@/assets/icons/more.svg";
import { formatSeconds } from "@/utils";
import OptionSvg from '@/assets/icons/more.svg'
import { formatSeconds } from '@/utils'
import HeartSvg from '../HeartSvg.vue'
defineProps<{
duration: number;
}>();
duration: number
is_fav: boolean
showFavIcon?: boolean
help_text?: string
}>()
defineEmits<{
(e: "showMenu", event: MouseEvent): void;
}>();
(e: 'showMenu', event: MouseEvent): void
}>()
</script>
<style lang="scss">
.songlist-item > .options-and-duration {
display: flex;
align-items: center;
gap: 1rem;
@include allPhones {
gap: $small;
justify-content: end;
margin-right: $small;
}
.song-duration {
font-size: small;
font-variant-numeric: tabular-nums;
text-align: left;
@include mediumPhones {
display: none;
}
}
.options-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1;
width: 2rem;
transition: background-color 0.2s ease-out;
justify-content: end;
gap: 1rem;
margin-right: $small;
position: relative;
svg {
stroke: $gray1;
@include allPhones {
gap: $small;
}
&:hover {
background-color: $gray3;
@include mediumPhones {
> .heart-icon.is-favorited {
display: none;
}
}
}
> .heart-icon.is-favorited {
display: block;
width: 28px;
height: 28px;
user-select: none;
pointer-events: none;
transition: opacity 0.2s ease-out;
@include mediumPhones {
display: none;
}
> .heart-button {
all: unset !important;
}
}
.song-duration {
font-size: small;
font-variant-numeric: tabular-nums;
text-align: left;
@include mediumPhones {
display: none;
}
transition: opacity 0.2s ease-out;
}
.song-duration.help-text {
position: absolute;
// INFO: 3 rem is the width of the options icon (2rem) plus the gap of the flex container (1rem)
right: 3rem;
font-size: $medium;
text-transform: uppercase;
color: $orange;
opacity: 0;
transition: opacity 0.2s ease-out;
@include allPhones {
right: 2.5rem;
}
}
.options-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1;
width: 2rem;
cursor: pointer;
transition: background-color 0.2s ease-out;
svg {
stroke: $gray1;
}
&:hover {
background-color: $gray3;
}
}
}
.songlist-item:hover > .options-and-duration > .heart-icon.is-favorited {
opacity: 0;
}
</style>

View File

@@ -25,6 +25,7 @@
<span class="title ellip">
{{ track.title }}
</span>
<ExplicitIcon class="explicit-icon" v-if="track.explicit" />
<MasterFlag :bitrate="track.bitrate" />
</div>
<div class="isSmallArtists">
@@ -40,6 +41,7 @@ const imguri = paths.images.thumb.small;
import ArtistName from "../ArtistName.vue";
import MasterFlag from "../MasterFlag.vue";
import ExplicitIcon from "@/assets/icons/explicit.svg";
import { paths } from "@/config";
@@ -59,6 +61,10 @@ defineEmits<{
position: relative;
align-items: center;
.explicit-icon {
margin-left: $small;
}
.thumbnail {
margin-right: $medium;
display: flex;

View File

@@ -0,0 +1,89 @@
// WARNING: EXPERIMENT!!!
import axios from 'axios'
export default async function usePlayer(url: string) {
const audioContext = new AudioContext()
const track = audioContext.createBufferSource()
const chunkSize = 524288 // 0.5MB in bytes
let totalSize = 0
let downloadedSize = 0
let playedSize = 0
const fetchAudioChunks = async (start: number, end: number) => {
const response = await axios.get(url, {
responseType: 'arraybuffer',
headers: {
Range: `bytes=${start}-${end}`,
},
withCredentials: true,
})
console.log('response', response.headers)
if (totalSize === 0) {
const contentRange = response.headers['content-range']
totalSize = parseInt(contentRange.split('/')[1])
}
console.log(response.data.byteLength)
const bytes = response.data as ArrayBuffer
downloadedSize += bytes.byteLength
console.log('byte length', bytes)
const audioData = await audioContext.decodeAudioData(bytes)
console.log('downloaded size', downloadedSize)
console.log('audiodata', audioData)
if (!track.buffer) {
track.buffer = audioData
} else {
const newBuffer = audioContext.createBuffer(
audioData.numberOfChannels,
track.buffer.length + audioData.length,
audioData.sampleRate
)
for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
newBuffer.copyToChannel(track.buffer.getChannelData(channel), channel, 0)
newBuffer.copyToChannel(audioData.getChannelData(channel), channel, track.buffer.length)
}
track.buffer = newBuffer
}
if (downloadedSize < totalSize) {
fetchAudioChunks(downloadedSize, Math.min(downloadedSize + chunkSize - 1, totalSize - 1))
}
}
await fetchAudioChunks(0, chunkSize)
track.connect(audioContext.destination)
track.onended = () => {
playedSize = totalSize
}
const updatePlayedSize = () => {
if (track.buffer) {
playedSize = Math.floor(track.context.currentTime * track.buffer.sampleRate)
if (playedSize + chunkSize > downloadedSize && downloadedSize < totalSize) {
fetchAudioChunks(downloadedSize, Math.min(downloadedSize + chunkSize - 1, totalSize - 1))
}
}
requestAnimationFrame(updatePlayedSize)
}
return {
track,
play: () => {
track.start()
updatePlayedSize()
},
pause: () => track.stop(),
getProgress: () => ({
downloaded: downloadedSize / totalSize,
played: playedSize / totalSize,
}),
}
}

View File

@@ -19,12 +19,12 @@ const imageRoutes = {
large: '/thumbnail/',
small: '/thumbnail/xsmall/',
smallish: '/thumbnail/small/',
medium: "/thumbnail/medium/"
medium: '/thumbnail/medium/',
},
artist: {
large: '/artist/',
small: '/artist/small/',
medium: "/artist/medium/"
medium: '/artist/medium/',
},
playlist: '/playlist/',
}
@@ -53,6 +53,9 @@ export const paths = {
artist: base_url + '/artist',
lyrics: base_url + '/lyrics',
plugins: base_url + '/plugins',
get mixes() {
return this.plugins + '/mixes'
},
// Single album
album: base_url + '/album',
@@ -82,6 +85,9 @@ export const paths = {
return this.base + '/artists'
},
},
pages: {
base: base_url + '/pages',
},
search: {
base: base_url + '/search',
get top() {
@@ -135,13 +141,13 @@ export const paths = {
get trigger_scan() {
return this.base + '/trigger-scan'
},
get updateConfig(){
return this.base + "/update"
}
get updateConfig() {
return this.base + '/update'
},
},
files: base_url + '/file',
home: {
base: base_url + '/home',
base: base_url + '/nothome',
get recentlyAdded() {
return this.base + '/recents/added'
},
@@ -166,7 +172,7 @@ export const paths = {
get addUser() {
return this.base + '/profile/create'
},
get addGuestUser(){
get addGuestUser() {
return this.base + '/profile/guest/create'
},
get updateProfile() {
@@ -175,9 +181,36 @@ export const paths = {
get deleteUser() {
return this.base + '/profile/delete'
},
get pair(){
get pair() {
return this.base + '/getpaircode'
}
},
},
backups: {
base: base_url + '/backup',
get get_backups() {
return this.base + '/list'
},
get create_backup() {
return this.base + '/create'
},
get restore_backup() {
return this.base + '/restore'
},
get delete_backup() {
return this.base + '/delete'
},
},
stats: {
base: base_url + '/logger',
get topArtists() {
return this.base + '/top-artists'
},
get topAlbums() {
return this.base + '/top-albums'
},
get topTracks() {
return this.base + '/top-tracks'
},
},
},
images: {
@@ -185,13 +218,17 @@ export const paths = {
small: baseImgUrl + imageRoutes.thumb.small,
smallish: baseImgUrl + imageRoutes.thumb.smallish,
large: baseImgUrl + imageRoutes.thumb.large,
medium: baseImgUrl + imageRoutes.thumb.medium
medium: baseImgUrl + imageRoutes.thumb.medium,
},
artist: {
small: baseImgUrl + imageRoutes.artist.small,
large: baseImgUrl + imageRoutes.artist.large,
medium: baseImgUrl + imageRoutes.artist.medium
medium: baseImgUrl + imageRoutes.artist.medium,
},
playlist: baseImgUrl + imageRoutes.playlist,
mix: {
medium: baseImgUrl + '/mix/medium/',
small: baseImgUrl + '/mix/small/',
},
},
}

View File

@@ -1,56 +1,109 @@
import useModal from "@/stores/modal";
import useAlbum from "@/stores/pages/album";
import useTracklist from "@/stores/queue/tracklist";
import { router, Routes } from '@/router'
import { Option, Playlist } from "@/interfaces";
import { addAlbumToPlaylist } from "@/requests/playlists";
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
import useAlbum from '@/stores/pages/album'
import useTracklist from '@/stores/queue/tracklist'
import usePage from '@/stores/pages/page'
export default async () => {
const album = useAlbum();
import { getAlbumTracks } from '@/requests/album'
import { addAlbumToPlaylist } from '@/requests/playlists'
import { addOrRemoveItemFromPage } from '@/requests/pages'
const play_next = <Option>{
label: "Play next",
action: () => {
const tracks = album.tracks.filter(
(track) => !track.is_album_disc_number
);
useTracklist().insertAfterCurrent(tracks);
},
icon: PlayNextIcon,
};
import { Album, Option, Page, Playlist, Track } from '@/interfaces'
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
import { getAddToPageOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'
const add_to_queue = <Option>{
label: "Add to queue",
action: () => {
const tracks = album.tracks.filter(
(track) => !track.is_album_disc_number
);
useTracklist().addTracks(tracks);
},
icon: AddToQueueIcon,
};
export default async (album?: Album) => {
const albumStore = useAlbum()
// Action for each playlist option
const AddToPlaylistAction = (playlist: Playlist) => {
const store = album;
addAlbumToPlaylist(playlist, store.info.albumhash);
};
if (!album) {
album = albumStore.info
}
const add_to_playlist: Option = {
label: "Add to Playlist",
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
albumhash: album.info.albumhash,
playlist_name: album.info.title,
}),
icon: PlusIcon,
};
const play_next = <Option>{
label: 'Play next',
action: async () => {
let tracks: Track[] = []
return [
play_next,
add_to_queue,
add_to_playlist,
get_find_on_social(),
];
};
if (album) {
tracks = await getAlbumTracks(album.albumhash)
} else {
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
}
useTracklist().insertAfterCurrent(tracks)
},
icon: PlayNextIcon,
}
const add_to_queue = <Option>{
label: 'Add to queue',
action: async () => {
let tracks: Track[] = []
if (album) {
tracks = await getAlbumTracks(album.albumhash)
} else {
tracks = albumStore.tracks.filter(track => !track.is_album_disc_number)
}
useTracklist().addTracks(tracks)
},
icon: AddToQueueIcon,
}
// Action for each playlist option
const AddToPlaylistAction = (playlist: Playlist) => {
addAlbumToPlaylist(playlist, album.albumhash)
}
const add_to_playlist: Option = {
label: 'Add to Playlist',
children: () =>
getAddToPlaylistOptions(AddToPlaylistAction, {
albumhash: album.albumhash,
playlist_name: album.title,
}),
icon: PlusIcon,
}
const addToPageAction = (page: Page) => {
addOrRemoveItemFromPage(page.id, album, 'album', 'add')
}
const add_to_page: Option = {
label: 'Add to Page',
children: () =>
getAddToPageOptions(addToPageAction, {
page: null,
hash: album.albumhash,
type: 'album',
extra: {},
}),
icon: PlusIcon,
}
const remove_from_page: Option = {
label: 'Remove from Page',
action: async () => {
const success = await addOrRemoveItemFromPage(
parseInt(router.currentRoute.value.params.page as string),
album,
'album',
'remove'
)
if (success) {
usePage().removeLocalItem(album, 'album')
}
},
icon: DeleteIcon,
}
return [
play_next,
add_to_queue,
add_to_playlist,
...[router.currentRoute.value.name === Routes.Page ? remove_from_page : add_to_page],
get_find_on_social('album', '', album),
]
}

View File

@@ -1,54 +1,102 @@
import modal from "@/stores/modal";
import useTracklist from "@/stores/queue/tracklist";
import { Routes } from '@/router'
import { router } from '@/router'
import { getArtistTracks } from "@/requests/artists";
import { addArtistToPlaylist } from "@/requests/playlists";
import usePage from '@/stores/pages/page'
import useTracklist from '@/stores/queue/tracklist'
import { Option, Playlist } from "@/interfaces";
import { getAddToPlaylistOptions, get_find_on_social } from "./utils";
import { AddToQueueIcon, PlayNextIcon, PlaylistIcon, PlusIcon } from "@/icons";
import { getArtistTracks } from '@/requests/artists'
import { addArtistToPlaylist } from '@/requests/playlists'
import { addOrRemoveItemFromPage } from '@/requests/pages'
import { Artist, Option, Page, Playlist } from '@/interfaces'
import { AddToQueueIcon, DeleteIcon, PlayNextIcon, PlusIcon } from '@/icons'
import { getAddToPageOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'
export default async (artisthash: string, artistname: string) => {
const play_next = <Option>{
label: "Play next",
action: () => {
getArtistTracks(artisthash).then((tracks) => {
const store = useTracklist();
store.insertAfterCurrent(tracks);
});
},
icon: PlayNextIcon,
};
const play_next = <Option>{
label: 'Play next',
action: () => {
getArtistTracks(artisthash).then(tracks => {
const store = useTracklist()
store.insertAfterCurrent(tracks)
})
},
icon: PlayNextIcon,
}
const add_to_queue = <Option>{
label: "Add to queue",
action: () => {
getArtistTracks(artisthash).then((tracks) => {
const store = useTracklist();
store.addTracks(tracks);
});
},
icon: AddToQueueIcon,
};
const add_to_queue = <Option>{
label: 'Add to queue',
action: () => {
getArtistTracks(artisthash).then(tracks => {
const store = useTracklist()
store.addTracks(tracks)
})
},
icon: AddToQueueIcon,
}
// Action for each playlist option
const AddToPlaylistAction = (playlist: Playlist) => {
addArtistToPlaylist(playlist, artisthash);
};
// Action for each playlist option
const AddToPlaylistAction = (playlist: Playlist) => {
addArtistToPlaylist(playlist, artisthash)
}
const add_to_playlist: Option = {
label: "Add to Playlist",
children: () => getAddToPlaylistOptions(AddToPlaylistAction, {
artisthash,
playlist_name: `This is ${artistname}`,
}),
icon: PlusIcon,
};
const add_to_playlist: Option = {
label: 'Add to Playlist',
children: () =>
getAddToPlaylistOptions(AddToPlaylistAction, {
artisthash,
playlist_name: `This is ${artistname}`,
}),
icon: PlusIcon,
}
return [
play_next,
add_to_queue,
add_to_playlist,
get_find_on_social("artist"),
];
};
const addToPageAction = (page: Page) => {
addOrRemoveItemFromPage(
page.id,
{
artisthash,
} as Artist,
'artist',
'add'
)
}
const add_to_page: Option = {
label: 'Add to Page',
children: () =>
getAddToPageOptions(addToPageAction, {
page: null,
hash: artisthash,
type: 'artist',
extra: {},
}),
icon: PlusIcon,
}
const remove_from_page: Option = {
label: 'Remove from Page',
action: async () => {
const success = await addOrRemoveItemFromPage(
parseInt(router.currentRoute.value.params.page as string),
{
artisthash,
} as Artist,
'artist',
'remove'
)
if (success) {
usePage().removeLocalItem({ artisthash } as Artist, 'artist')
}
},
icon: DeleteIcon,
}
return [
play_next,
add_to_queue,
add_to_playlist,
...[router.currentRoute.value.name === Routes.Page ? remove_from_page : add_to_page],
get_find_on_social('artist'),
]
}

View File

@@ -0,0 +1,22 @@
import crypto from 'crypto';
export function getLastFmApiSig(data: {[key: string]: any}, secret: string): string {
// Sort keys alphabetically
const sortedKeys = Object.keys(data).sort();
// Concatenate parameters in name+value format
const concatenatedString = sortedKeys.reduce((acc, key) => {
// Ensure values are properly encoded
const value = encodeURIComponent(data[key].toString());
return acc + key + value;
}, '');
// Append secret
const stringToHash = concatenatedString + secret;
// Generate MD5 hash
return crypto.createHash('md5')
.update(stringToHash)
.digest('hex');
}

View File

@@ -3,16 +3,15 @@ import useAlbum from '@/stores/pages/album'
import useArtist from '@/stores/pages/artist'
import { SearchIcon } from '@/icons'
import { Option, Playlist } from '@/interfaces'
import { Album, Option, Page, Playlist } from '@/interfaces'
import { getAllPages } from '@/requests/pages'
import { getAllPlaylists } from '@/requests/playlists'
export const separator: Option = {
type: 'separator',
}
export function get_new_playlist_option(
new_playlist_modal_props: any = {}
): Option {
export function get_new_playlist_option(new_playlist_modal_props: any = {}): Option {
return {
label: 'New playlist',
action: () => {
@@ -21,6 +20,15 @@ export function get_new_playlist_option(
}
}
export function get_new_page_option(new_playlist_modal_props: any = {}): Option {
return {
label: 'New page',
action: () => {
modal().showPageModal(new_playlist_modal_props)
},
}
}
type action = (playlist: Playlist) => void
/**
@@ -29,10 +37,7 @@ type action = (playlist: Playlist) => void
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
* @returns A list of options to be used in a context menu
*/
export async function getAddToPlaylistOptions(
addToPlaylist: action,
new_playlist_modal_props: any = {}
) {
export async function getAddToPlaylistOptions(addToPlaylist: action, new_playlist_modal_props: any = {}) {
const new_playlist = get_new_playlist_option(new_playlist_modal_props)
const p = await getAllPlaylists(true)
@@ -44,7 +49,7 @@ export async function getAddToPlaylistOptions(
let playlists = <Option[]>[]
playlists = p.map((playlist) => {
playlists = p.map(playlist => {
return <Option>{
label: playlist.name,
action: () => {
@@ -56,20 +61,42 @@ export async function getAddToPlaylistOptions(
return [...items, separator, ...playlists]
}
export const get_find_on_social = (page = 'album', query = '') => {
/**
*
* @param addToPlaylist Function to be called when a playlist is selected
* @param new_playlist_modal_props Props to be passed to the modal when creating a new playlist
* @returns A list of options to be used in a context menu
*/
export async function getAddToPageOptions(addToPage: (page: Page) => void, new_page_modal_props: any = {}) {
const new_page = get_new_page_option(new_page_modal_props)
const p = await getAllPages()
let items = [new_page]
if (p.length === 0) {
return items
}
let pages = <Option[]>[]
pages = p.map(playlist => {
return <Option>{
label: playlist.name,
action: () => {
addToPage(playlist)
},
}
})
return [...items, separator, ...pages]
}
export const get_find_on_social = (page = 'album', query = '', album?: Album) => {
const is_album = page === 'album'
const getAlbumSearchTerm = () => {
const store = useAlbum()
return `${store.info.title} - ${store.info.albumartists
.map((a) => a.name)
.join(', ')}`
return `${album?.title} - ${album?.albumartists.map(a => a.name).join(', ')}`
}
const search_term = query
? query
: is_album
? getAlbumSearchTerm()
: useArtist().info.name
const search_term = query ? query : is_album ? getAlbumSearchTerm() : useArtist().info.name
return <Option>{
label: 'Search on',
@@ -77,67 +104,36 @@ export const get_find_on_social = (page = 'album', query = '') => {
children: async () => [
{
label: 'Google',
action: () =>
window.open(
`https://www.google.com/search?q=${search_term}`,
'_blank'
),
action: () => window.open(`https://www.google.com/search?q=${search_term}`, '_blank'),
},
{
label: 'YouTube',
action: () =>
window.open(
`https://www.youtube.com/results?search_query=${search_term}`,
'_blank'
),
action: () => window.open(`https://www.youtube.com/results?search_query=${search_term}`, '_blank'),
},
{
label: 'Spotify',
action: () =>
window.open(
`https://open.spotify.com/search/${search_term}/${page}s`,
'_blank'
),
action: () => window.open(`https://open.spotify.com/search/${search_term}/${page}s`, '_blank'),
},
{
label: 'Tidal',
action: () =>
window.open(
`https://listen.tidal.com/search/${page}s?q=${search_term}`,
'_blank'
),
action: () => window.open(`https://listen.tidal.com/search/${page}s?q=${search_term}`, '_blank'),
},
{
label: 'Apple Music',
action: () =>
window.open(
`https://music.apple.com/search?term=${search_term}`,
'_blank'
),
action: () => window.open(`https://music.apple.com/search?term=${search_term}`, '_blank'),
},
{
label: 'Deezer',
action: () =>
window.open(
`https://www.deezer.com/search/${search_term}/${page}`,
'_blank'
),
action: () => window.open(`https://www.deezer.com/search/${search_term}/${page}`, '_blank'),
},
{
label: 'Wikipedia',
action: () =>
window.open(
`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`,
'_blank'
),
window.open(`https://en.wikipedia.org/wiki/Special:Search?search=${search_term}`, '_blank'),
},
{
label: 'Last.fm',
action: () =>
window.open(
`https://www.last.fm/search/${page}s?q=${search_term}`,
'_blank'
),
action: () => window.open(`https://www.last.fm/search/${page}s?q=${search_term}`, '_blank'),
},
],
}

View File

@@ -8,6 +8,7 @@ export enum playSources {
track,
recentlyAdded,
recentlyPlayed,
mix,
}
export enum NotifType {
@@ -24,6 +25,8 @@ export enum FromOptions {
album = "album",
search = "search",
artist = "artist",
mix = "mix",
artistMix = "artist mix",
albumCard = "albumCard",
favorite = "favorite",
}
@@ -99,4 +102,7 @@ export interface DBSettings {
scanInterval: number
plugins: Plugin[];
version: string;
lastfmApiKey: string;
lastfmApiSecret: string;
lastfmSessionKey: string;
}

View File

@@ -1,88 +1,78 @@
import { Store } from "pinia";
import { Ref } from "vue";
import { useRoute } from "vue-router";
import { Store } from 'pinia'
import { Ref } from 'vue'
import { useRoute } from 'vue-router'
import { ContextSrc } from "@/enums";
import { Track } from "@/interfaces";
import useContextStore from "@/stores/context";
import { ContextSrc } from '@/enums'
import { Album, Track } from '@/interfaces'
import useContextStore from '@/stores/context'
import albumContextItems from "@/context_menus/album";
import artistContextItems from "@/context_menus/artist";
import folderContextItems from "@/context_menus/folder";
import trackContextItems from "@/context_menus/track";
import queueContextItems from "@/context_menus/queue";
import albumContextItems from '@/context_menus/album'
import artistContextItems from '@/context_menus/artist'
import folderContextItems from '@/context_menus/folder'
import trackContextItems from '@/context_menus/track'
import queueContextItems from '@/context_menus/queue'
let stop_prev_watcher = () => {};
let stop_prev_watcher = () => {}
function flagWatcher(menu: Store, flag: Ref<boolean>) {
stop_prev_watcher();
stop_prev_watcher()
if (flag.value) {
return (flag.value = false);
}
if (flag.value) {
return (flag.value = false)
}
// watch for context menu visibility and reset flag
stop_prev_watcher = menu.$subscribe((mutation, state) => {
//@ts-ignore
flag.value = state.visible;
});
// watch for context menu visibility and reset flag
stop_prev_watcher = menu.$subscribe((mutation, state) => {
//@ts-ignore
flag.value = state.visible
})
}
export const showTrackContextMenu = (
e: MouseEvent,
track: Track,
flag: Ref<boolean>,
) => {
const menu = useContextStore();
const options = () => trackContextItems(track);
export const showTrackContextMenu = (e: MouseEvent, track: Track, flag: Ref<boolean>) => {
const menu = useContextStore()
const options = () => trackContextItems(track)
menu.showContextMenu(e, options, ContextSrc.Track);
menu.showContextMenu(e, options, ContextSrc.Track)
flagWatcher(menu, flag);
};
flagWatcher(menu, flag)
}
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
const menu = useContextStore();
export const showAlbumContextMenu = (e: MouseEvent, flag: Ref<boolean>, album?: Album) => {
const menu = useContextStore()
const options = () => albumContextItems();
menu.showContextMenu(e, options, ContextSrc.AlbumHeader);
const options = () => albumContextItems(album)
menu.showContextMenu(e, options, ContextSrc.AlbumHeader)
flagWatcher(menu, flag);
};
flagWatcher(menu, flag)
}
export const showFolderContextMenu = (
e: MouseEvent,
flag: Ref<boolean>,
source: ContextSrc,
path: string
) => {
const menu = useContextStore();
export const showFolderContextMenu = (e: MouseEvent, flag: Ref<boolean>, source: ContextSrc, path: string) => {
const menu = useContextStore()
const options = () => folderContextItems(path);
menu.showContextMenu(e, options, source);
const options = () => folderContextItems(path)
menu.showContextMenu(e, options, source)
flagWatcher(menu, flag);
};
flagWatcher(menu, flag)
}
export const showArtistContextMenu = (
e: MouseEvent,
flag: Ref<boolean>,
artisthash: string,
artistname: string
) => {
const menu = useContextStore();
export const showArtistContextMenu = (e: MouseEvent, flag: Ref<boolean>, artisthash: string, artistname: string) => {
const menu = useContextStore()
const options = () => artistContextItems(artisthash, artistname);
menu.showContextMenu(e, options, ContextSrc.ArtistHeader);
const options = () => artistContextItems(artisthash, artistname)
menu.showContextMenu(e, options, ContextSrc.ArtistHeader)
flagWatcher(menu, flag);
};
flagWatcher(menu, flag)
}
export const showQueueContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
const menu = useContextStore();
const menu = useContextStore()
const options = () => queueContextItems();
menu.showContextMenu(e, options, ContextSrc.Queue);
const options = () => queueContextItems()
menu.showContextMenu(e, options, ContextSrc.Queue)
flagWatcher(menu, flag);
};
flagWatcher(menu, flag)
}
// export const showAlbumCardContextMenu = (e: MouseEvent, flag: Ref<boolean>, album: Album) => {
// }

View File

@@ -1,67 +1,81 @@
import { paths } from "../config";
import useQueueStore from "../stores/queue";
import { paths } from '../config'
import useSettings from '../stores/settings'
import useQueueStore from '../stores/queue'
import updatePageTitle from '@/utils/updatePageTitle'
export default () => {
if ("mediaSession" in navigator) {
const queue = useQueueStore();
const { currenttrack: track } = queue;
if ('mediaSession' in navigator) {
const queue = useQueueStore()
const { currenttrack: track } = queue
if (track === undefined) {
return;
if (track === undefined) {
return
}
const settings = useSettings()
if (settings.nowPlayingTrackOnTabTitle) {
updatePageTitle(`${track.title} - ${track.artists[0].name}`, true)
}
const url = paths.images.thumb
navigator.mediaSession.metadata = new window.MediaMetadata({
title: track.title,
album: track.album,
artist: track.artists.map(a => a.name).join(', '),
artwork: [
{
src: url.small + track.image,
sizes: '96x96',
type: 'image/jpeg',
},
{
src: url.medium + track.image,
sizes: '128x128',
type: 'image/webp',
},
{
src: url.medium + track.image,
sizes: '192x192',
type: 'image/webp',
},
{
src: url.medium + track.image,
sizes: '256x256',
type: 'image/webp',
},
{
src: url.large + track.image,
sizes: '384x384',
type: 'image/webp',
},
{
src: url.large + track.image,
sizes: '512x512',
type: 'image/webp',
},
],
})
navigator.mediaSession.setActionHandler('play', () => {
queue.playPause()
})
navigator.mediaSession.setActionHandler('pause', () => {
queue.playPause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
queue.playPrev()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
queue.playNext()
})
navigator.mediaSession.setActionHandler('seekto', details => {
if (details.fastSeek || details.seekTime == undefined) {
return
}
queue.seek(details.seekTime)
})
}
const url = paths.images.thumb;
navigator.mediaSession.metadata = new window.MediaMetadata({
title: track.title,
album: track.album,
artist: track.artists.map((a) => a.name).join(", "),
artwork: [
{
src: url.small + track.image,
sizes: "96x96",
type: "image/jpeg",
},
{
src: url.medium + track.image,
sizes: "128x128",
type: "image/webp",
},
{
src: url.medium + track.image,
sizes: "192x192",
type: "image/webp",
},
{
src: url.medium + track.image,
sizes: "256x256",
type: "image/webp",
},
{
src: url.large + track.image,
sizes: "384x384",
type: "image/webp",
},
{
src: url.large + track.image,
sizes: "512x512",
type: "image/webp",
},
],
});
navigator.mediaSession.setActionHandler("play", () => {
queue.playPause();
});
navigator.mediaSession.setActionHandler("pause", () => {
queue.playPause();
});
navigator.mediaSession.setActionHandler("previoustrack", () => {
queue.playPrev();
});
navigator.mediaSession.setActionHandler("nexttrack", () => {
queue.playNext();
});
}
};
}

View File

@@ -106,7 +106,7 @@ export async function playFromFavorites(track: Track | undefined) {
export async function playFromPlaylist(id: string, track?: Track) {
const queue = useQueue()
const tracklist = useTracklist()
const data = await getPlaylist(id)
const data = await getPlaylist(id, false, 0, -1)
if (!data) return

Some files were not shown because too many files have changed in this diff Show More