Compare commits

...

101 Commits

Author SHA1 Message Date
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
cwilvx
0a54aa2c70 fix: sort bar on default layout
+ persist folder tracks sort options
2024-08-31 12:07:40 +03:00
cwilvx
212c76ed0d fix: playing from folder not fetching all tracks 2024-08-31 11:26:08 +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
cwilvx
ee578ae9de add sort dropdown 2024-08-22 20:16:23 +03:00
cwilvx
2ee13a8531 fix play icon size on buttons 2024-08-18 06:49:39 +03:00
cwilvx
753d38be14 implement playing a single disc in an album 2024-08-17 13:53:07 +03:00
cwilvx
b20d39935b write log timestamp when updating duration 2024-08-04 19:21:09 +03:00
cwilvx
a11f64e4d2 port settings to config 2024-08-04 10:24:42 +03: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
cwilvx
9864a71bae fix: wrong scrobble timestamp 2024-07-20 00:22:54 +03:00
cwilvx
ba3d9e63f8 paginate playlist view 2024-07-19 23:46:09 +03:00
cwilvx
0b1730e7bb implement api changes 2024-07-15 20:12:01 +03:00
cwilvx
746d783544 fix: folder page scroll to top on new route 2024-07-13 13:08:40 +03:00
cwilvx
b7410c4b35 fix: items not being cleared on new route 2024-07-13 13:05:58 +03:00
cwilvx
8014b2a1cb paginate folders 2024-07-13 12:38:31 +03:00
cwilvx
b3d4732cbb sort artist albums by album artist appearance 2024-07-05 04:41:27 +03:00
Simonh2o
09bb5ae7b0 added spacing between scan and settings item, equal to what settings menu has 2024-07-04 21:36:43 +02:00
cwilvx
1ed4777bc0 fix: album page more from artist 2024-07-04 13:48:29 +03:00
cwilvx
8afac9af6c add setting to enable periodic scans, watchdog and now playing track on tab title
+ move avatar+settings dropdown to component
+ fix: use create sub paths error
+ increase settings dialog height
+ add avatar to right sidebar search bar
+ add free number input component
+ misc edits
2024-07-04 11:46:16 +03: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
cwilvx
f2567d2897 fix: format Date on album header 2024-07-03 15:58:20 +03:00
cwilvx
29b4f078eb move stat sort keys to the right 2024-06-30 19:21:45 +03:00
cwilvx
64e1ba317c paginate favorite pages
+ refactor: album store albumArtists -> artistAlbums
+ properly handle server signature error
+ add generic track scroller page
+ update color attribute
2024-06-30 15:20:20 +03: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
cwilvx
5bbcbfcbcd fix: useCreateSubPaths error 2024-06-22 00:10:30 +03:00
cwilvx
6211c8d6c0 remove console.logs 2024-06-19 22:30:43 +03:00
cwilvx
fac86b68f5 rename legacy endpoint 2024-06-19 14:58:19 +03:00
cwilvx
0edd3bff43 add setting to enable legacy streaming endpoint 2024-06-19 10:23:35 +03:00
cwilvx
483130b068 fix: buffered indicator on seek 2024-06-18 22:13:29 +03:00
cwilvx
0706cedaab reset buffer on new track 2024-06-18 21:40:44 +03:00
cwilvx
ca4cb3c4a3 show buffered audio on progress bar
+ add icons to avatar dropdown
+ add option to trigger scan on that dropdown
+ fix: adding folder to new folder not populating playlist name
+ add prettier config
2024-06-18 21:17:36 +03:00
cwilvx
e7850c39f9 add quick scan item to homepage 2024-06-12 14:11:41 +03:00
cwilvx
313a2352cb remove settings popup 2024-06-09 21:18:01 +03:00
cwilvx
b6a95b1669 add pairing UI 2024-06-09 21:16:27 +03:00
cwilvx
1ec1d23cf3 fix: hide remove from playlist on system playlists
+ fix: passing route to track context handler
2024-06-09 13:02:54 +03:00
cwilvx
c78866f516 fix: hide recents on home if they're empty
+ fix: audio not muted on queue repeat
2024-06-09 12:22:38 +03:00
Mungai Njoroge
6b9d223bc3 Merge PR #32 from @Simonh2o
Minor fixes and additions. 

Custom scrollbar logic is back (targets windows & linux @chrome only). Changed some placeholders for account dropdown and a few spacing improvements to new modal settings page.
2024-06-08 00:25:45 +03:00
Simon
472e9f5114 Merge branch 'swingmx:master' into master 2024-05-28 21:25:34 +02:00
cwilvx
51e1f39761 fix: appending to queue adding to last index -1 2024-05-28 22:06:37 +03:00
Simon
37ee4adb04 Merge branch 'swingmx:master' into master 2024-05-23 20:31:00 +02:00
cwilvx
daab935193 add recently played playlist url 2024-05-23 12:41:41 +03:00
Simonh2o
edcf8b884c Added spacing and transitions to settings modal/dropdown. Improved responsive layout for settings 2024-05-23 01:58:36 +02:00
Simonh2o
d7f97f4cd4 Added back class logic for custom scroll 2024-05-23 01:56:16 +02:00
cwilvx
7e917e40a6 rework playlist and album headers for small displays 2024-05-22 14:55:44 +03:00
cwilvx
25d1684b1f fix: breadcrumb shifts
+ use smallish thumbnails on playlist card
2024-05-22 14:13:38 +03:00
cwilvx
a8c246c32b make settings dialog responsive 2024-05-22 12:36:53 +03:00
cwilvx
813bb1caac change album header background pallete from DarkVibrant to DarkMuted 2024-05-22 11:44:12 +03:00
cwilvx
b7481a5de4 fix mobile navbar svg size 2024-05-21 23:38:17 +03:00
cwilvx
0a96ac7387 fix shift on track item 2024-05-21 21:13:19 +03:00
cwilvx
5f15855ac2 merge save as playlist with add to playlist 2024-05-21 20:45:55 +03:00
cwilvx
ac493b60d6 extend async context children to other context menus 2024-05-18 21:09:31 +03:00
cwilvx
4eee813ad9 make context menu popup faster
+ lazy load playlists
2024-05-17 22:11:47 +03:00
cwilvx
a442f57b7d maintain playlist header height on large screens 2024-05-17 20:36:24 +03:00
cwilvx
15d9d7fec6 fix: playllists new playlist button svg 2024-05-17 20:31:47 +03:00
cwilvx
0943bc8015 add generic headers to favorite albums and artists 2024-05-17 20:26:35 +03:00
cwilvx
8c86874ae5 add generic header to favorite tracks page 2024-05-17 20:15:24 +03:00
cwilvx
a3fe3968ca show account settings users as clickable 2024-05-17 19:57:31 +03:00
cwilvx
d154a886d1 fix: wrong link on home page browse's fav album link 2024-05-17 11:29:48 +03:00
cwilvx
e0ec2f3c68 remove track hover effect on mobile 2024-05-16 22:38:57 +03:00
cwilvx
9207602a26 replace clear search input with an svg 2024-05-16 22:34:52 +03:00
cwilvx
512307cfd2 reduce folder item card height 2024-05-16 22:24:54 +03:00
cwilvx
7b8846da9c update breadcrumb on skipped folders 2024-05-16 22:22:18 +03:00
cwilvx
2ae24b73ca add icons to home page browse items
+ move homepage browse to top of page
2024-05-15 16:45:59 +03:00
cwilvx
43c45b5893 fix card calculation
+ remove welcome dialog function calls
+ hook the left nav settings button to the new settings dialog
2024-05-14 11:11:50 +03:00
cwilvx
3f47dd3d02 Merge branch 'introducing-auth' 2024-05-11 21:54:51 +03:00
Mungai Njoroge
5e0de20485 Add auth stuff
Let's gooo!
2024-05-11 14:29:01 -04:00
188 changed files with 7914 additions and 5427 deletions

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 120
}

43
TODO.md
View File

@@ -1,2 +1,43 @@
# TODO 📦
- Track share page
- Add right-click option to copy track url
- Check out the mobile sidebar and navbar
- Remove old settings page files
- Fix: track loading indicator in bottom bar
- Unfuck javascript controlled responsiveness
- Redesign the album page header for mobile
- Merge all cards into one generic card or something! ... for better control and updates. ie. have a layout card to controls the sections and general style. Use slots, props and emits to create child components.
- Merge sidenav dimmer and modal dimmer
- Fix: Add to favorite button on headers icon alignment
- Add trailing slash to folder url accessed from the breadcrumb
- Clip the browseable items on the homepage
- Fix: The responsiveness glitch between 900px - 964px 😅
- Fix: Queue repeat
- Make All Albums/Artists view sort banner sticky
# DONE ✅
- Remove welcome dialog
- Update folder page breadcrumb when response has skipped empty folders
- Reduce folder item height
- Fix max album cards calculator
- Replace the search input X with an SVG
- Remove track item hover effect on mobile view
- Add auth info to home page greetings. eg. Good afternoon cwilvx
- Update folder page breadcrumb when response has skipped empty folders
- Rewrite context menu to only fetch server side data when you need it:
- WHY: To remove popup delays!
- REVIEW: Is this really what we need?
- HOW: eg. fetch playlists when you hover/click "Add to playlist"
- IDEA: Maybe have a store for available playlists, and fetch new items when you read the store? Or something!
- Add generic headers to favorite subpages
- Fix: Edit playlist button hiding on playlist update
- Merge "Save as playlist" with "Add to playlist > New Playlist"
- Fix: Tracklist item index slightly shifts up and down on hover/unhover
- Settings dialog responsiveness
- Fix: Breadcrumb align center (shifts when the the position of the highlighted folder is auto-scrolled to)
- Fix: Audio not being muted (when audio is muted by user) on queue repeat
- hide "remove from playlist" option on system playlists
- ADD QR CODE SOMEWHERE ON THE WEB
- Add a "Rescan folders" item to the "Browse Library" section
- Paginate playlist page

View File

@@ -22,6 +22,7 @@
"node-vibrant": "3.1.6",
"pinia": "^2.0.17",
"pinia-plugin-persistedstate": "^3.2.0",
"qr-code-styling": "^1.6.0-rc.1",
"v-wave": "^1.5.0",
"vue": "^v3.2.45",
"vue-boring-avatars": "^1.4.0",

3
public/icons/heart.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg fill="currentColor" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path d="M5.09668 11.1846C5.09668 14.9375 8.25195 18.6465 13.1562 21.8105C13.4287 21.9863 13.7627 22.1621 13.9912 22.1621C14.2197 22.1621 14.5537 21.9863 14.8262 21.8105C19.7393 18.6465 22.8857 14.9375 22.8857 11.1846C22.8857 7.94141 20.6445 5.69141 17.7705 5.69141C16.0918 5.69141 14.7822 6.45605 13.9912 7.61621C13.2178 6.46484 11.8994 5.69141 10.2207 5.69141C7.33789 5.69141 5.09668 7.94141 5.09668 11.1846ZM6.90723 11.1758C6.90723 8.96094 8.36621 7.45801 10.3262 7.45801C11.9082 7.45801 12.7959 8.41602 13.3496 9.25098C13.5957 9.61133 13.7627 9.72559 13.9912 9.72559C14.2285 9.72559 14.3779 9.60254 14.6328 9.25098C15.2305 8.43359 16.083 7.45801 17.6562 7.45801C19.625 7.45801 21.084 8.96094 21.084 11.1758C21.084 14.2695 17.8672 17.6973 14.1582 20.1582C14.0791 20.2109 14.0264 20.2461 13.9912 20.2461C13.9561 20.2461 13.9033 20.2109 13.833 20.1582C10.124 17.6973 6.90723 14.2695 6.90723 11.1758Z" />
</svg>

After

Width:  |  Height:  |  Size: 993 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.16016 9.50586C6.85449 9.50586 7.4082 8.95215 7.4082 8.25781C7.4082 7.57227 6.85449 7.00977 6.16016 7.00977C5.47461 7.00977 4.91211 7.57227 4.91211 8.25781C4.91211 8.95215 5.47461 9.50586 6.16016 9.50586ZM10.291 9.10156H22.2266C22.7012 9.10156 23.0791 8.73242 23.0791 8.25781C23.0791 7.7832 22.71 7.41406 22.2266 7.41406H10.291C9.8252 7.41406 9.44727 7.7832 9.44727 8.25781C9.44727 8.73242 9.81641 9.10156 10.291 9.10156ZM6.16016 14.9111C6.85449 14.9111 7.4082 14.3574 7.4082 13.6631C7.4082 12.9775 6.85449 12.415 6.16016 12.415C5.47461 12.415 4.91211 12.9775 4.91211 13.6631C4.91211 14.3574 5.47461 14.9111 6.16016 14.9111ZM10.291 14.5068H22.2266C22.7012 14.5068 23.0791 14.1377 23.0791 13.6631C23.0791 13.1885 22.71 12.8193 22.2266 12.8193H10.291C9.8252 12.8193 9.44727 13.1885 9.44727 13.6631C9.44727 14.1377 9.81641 14.5068 10.291 14.5068ZM6.16016 20.3164C6.85449 20.3164 7.4082 19.7627 7.4082 19.0684C7.4082 18.3828 6.85449 17.8203 6.16016 17.8203C5.47461 17.8203 4.91211 18.3828 4.91211 19.0684C4.91211 19.7627 5.47461 20.3164 6.16016 20.3164ZM10.291 19.9121H22.2266C22.7012 19.9121 23.0791 19.543 23.0791 19.0684C23.0791 18.5938 22.71 18.2246 22.2266 18.2246H10.291C9.8252 18.2246 9.44727 18.5938 9.44727 19.0684C9.44727 19.543 9.81641 19.9121 10.291 19.9121Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1035 23.208H14.8877C15.6172 23.208 16.1885 22.751 16.3643 22.0479L16.7158 20.5098L16.9443 20.4219L18.2891 21.2568C18.9043 21.6436 19.6338 21.5381 20.1523 21.0195L21.3828 19.7891C21.9102 19.2617 21.998 18.541 21.6113 17.9346L20.7764 16.5898L20.8643 16.3789L22.4023 16.0186C23.0967 15.8428 23.5537 15.2715 23.5537 14.542V12.8105C23.5537 12.0811 23.1055 11.5098 22.4023 11.334L20.873 10.9648L20.7852 10.7363L21.6201 9.40039C22.0068 8.79395 21.9189 8.07324 21.3916 7.53711L20.1611 6.30664C19.6514 5.78809 18.9219 5.69141 18.3066 6.06934L16.9619 6.89551L16.7158 6.80762L16.3643 5.26074C16.1885 4.55762 15.6172 4.10938 14.8877 4.10938H13.1035C12.3652 4.10938 11.7939 4.55762 11.627 5.26074L11.2754 6.80762L11.0293 6.89551L9.68457 6.06934C9.06055 5.69141 8.33984 5.78809 7.83008 6.30664L6.59082 7.53711C6.07227 8.07324 5.97559 8.79395 6.3623 9.40039L7.19727 10.7363L7.10938 10.9648L5.58887 11.334C4.88574 11.5098 4.4375 12.0811 4.4375 12.8105V14.542C4.4375 15.2715 4.89453 15.8428 5.58887 16.0186L7.12695 16.3789L7.20605 16.5898L6.37109 17.9346C5.98438 18.541 6.08105 19.2617 6.59961 19.7891L7.83887 21.0195C8.34863 21.5381 9.07812 21.6436 9.69336 21.2568L11.0381 20.4219L11.2754 20.5098L11.627 22.0479C11.7939 22.751 12.3652 23.208 13.1035 23.208ZM13.332 21.5908C13.1826 21.5908 13.1035 21.5293 13.0859 21.3975L12.5586 19.2354C12.0049 19.1035 11.4688 18.875 11.0381 18.6025L9.13965 19.7715C9.02539 19.8418 8.91992 19.833 8.81445 19.7275L7.8916 18.8047C7.78613 18.708 7.78613 18.6025 7.85645 18.4883L9.02539 16.5898C8.7793 16.168 8.55078 15.6406 8.41895 15.0869L6.24805 14.5684C6.11621 14.5508 6.0459 14.4717 6.0459 14.3223V13.0215C6.0459 12.8633 6.10742 12.8018 6.24805 12.7666L8.41016 12.2568C8.54199 11.668 8.79688 11.123 9.0166 10.7275L7.84766 8.84668C7.77734 8.72363 7.77734 8.61816 7.87402 8.5127L8.80566 7.59863C8.91113 7.50195 9.00781 7.48438 9.13965 7.56348L11.0205 8.71484C11.416 8.46875 12.0049 8.22266 12.5674 8.08203L13.0859 5.91992C13.1035 5.78809 13.1826 5.71777 13.332 5.71777H14.6592C14.8086 5.71777 14.8789 5.7793 14.9053 5.91992L15.4326 8.09082C16.0039 8.23145 16.5225 8.46875 16.9619 8.71484L18.8428 7.56348C18.9746 7.49316 19.0713 7.50195 19.1768 7.60742L20.1084 8.52148C20.2139 8.61816 20.2139 8.72363 20.1348 8.84668L18.9746 10.7275C19.1855 11.123 19.4492 11.668 19.5811 12.2568L21.7432 12.7666C21.8838 12.8018 21.9365 12.8633 21.9365 13.0215V14.3223C21.9365 14.4717 21.875 14.5508 21.7432 14.5684L19.5723 15.0869C19.4404 15.6406 19.2031 16.1768 18.957 16.5898L20.126 18.4795C20.1963 18.6025 20.1963 18.6992 20.0908 18.7959L19.168 19.7275C19.0625 19.833 18.957 19.8418 18.8428 19.7715L16.9531 18.6025C16.5137 18.875 16.0127 19.0947 15.4326 19.2354L14.9053 21.3975C14.8789 21.5293 14.8086 21.5908 14.6592 21.5908H13.332ZM14 16.9941C15.8281 16.9941 17.3311 15.4912 17.3311 13.6543C17.3311 11.835 15.8281 10.332 14 10.332C12.1631 10.332 10.6514 11.835 10.6514 13.6543C10.6514 15.4912 12.1631 16.9941 14 16.9941ZM14 15.4736C12.998 15.4736 12.1807 14.6562 12.1807 13.6543C12.1807 12.6699 13.0068 11.8525 14 11.8525C14.9756 11.8525 15.793 12.6699 15.793 13.6543C15.793 14.6475 14.9756 15.4736 14 15.4736Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

View File

@@ -2,11 +2,7 @@
<ContextMenu />
<Modal />
<Notification />
<div
id="drag-img"
class="ellip2"
style=""
></div>
<div id="drag-img" class="ellip2" style=""></div>
<section
id="app-grid"
:class="{
@@ -16,25 +12,13 @@
is_alt_layout: settings.is_alt_layout,
}"
:style="{
maxWidth: `${
settings.is_default_layout
? content_height > 1080
? '2220px'
: '1760px'
: ''
}`,
maxWidth: `${settings.is_default_layout ? (content_height > 1080 ? '2220px' : '1760px') : ''}`,
}"
>
<LeftSidebar v-if="settings.is_default_layout && !isMobile" />
<NavBar />
<div
id="acontent"
v-element-size="updateContentElemSize"
>
<div
id="contentresizer"
ref="appcontent"
></div>
<div id="acontent" v-element-size="updateContentElemSize">
<div id="contentresizer" ref="appcontent"></div>
<BalancerProvider>
<RouterView />
</BalancerProvider>
@@ -47,150 +31,146 @@
<script setup lang="ts">
// @libraries
import { vElementSize } from '@vueuse/components'
import { onStartTyping } from '@vueuse/core'
import { onMounted, Ref, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BalancerProvider } from 'vue-wrap-balancer'
import { vElementSize } from "@vueuse/components";
import { onStartTyping } from "@vueuse/core";
import { onMounted, Ref, ref } from "vue";
import { useRouter } from "vue-router";
import { BalancerProvider } from "vue-wrap-balancer";
// @stores
import {
content_height,
content_width,
isMobile,
resizer_height,
resizer_width,
updateCardWidth,
} from '@/stores/content-width'
import useLyrics from '@/stores/lyrics'
import useModal from '@/stores/modal'
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import useTracker from '@/stores/tracker'
import useAuth from '@/stores/auth'
import useAuth from "@/stores/auth";
import { content_height, content_width, isMobile, resizer_width, updateCardWidth } from "@/stores/content-width";
import useLyrics from "@/stores/lyrics";
import useModal from "@/stores/modal";
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useTracker from "@/stores/tracker";
// @utils
import handleShortcuts from '@/helpers/useKeyboard'
import { readLocalStorage, writeLocalStorage } from '@/utils'
import { xl, xxl } from './composables/useBreakpoints'
import handleShortcuts from "@/helpers/useKeyboard";
import { xl, xxl } from "./composables/useBreakpoints";
// @small-components
import ContextMenu from '@/components/ContextMenu.vue'
import Modal from '@/components/modal.vue'
import Notification from '@/components/Notification.vue'
import ContextMenu from "@/components/ContextMenu.vue";
import Modal from "@/components/modal.vue";
import Notification from "@/components/Notification.vue";
// @app-grid-components
import BottomBar from '@/components/BottomBar/BottomBar.vue'
import LeftSidebar from '@/components/LeftSidebar/index.vue'
import NavBar from '@/components/nav/NavBar.vue'
import RightSideBar from '@/components/RightSideBar/Main.vue'
import BottomBar from "@/components/BottomBar/BottomBar.vue";
import LeftSidebar from "@/components/LeftSidebar/index.vue";
import NavBar from "@/components/nav/NavBar.vue";
import RightSideBar from "@/components/RightSideBar/Main.vue";
import { getLoggedInUser } from './requests/auth'
import { getAllSettings } from '@/requests/settings'
import { getRootDirs } from '@/requests/settings/rootdirs'
import { getAllSettings } from "@/requests/settings";
import { getRootDirs } from "@/requests/settings/rootdirs";
import { getLoggedInUser } from "./requests/auth";
// import BubbleManager from "./components/bubbles/BinManager.vue";
const appcontent: Ref<HTMLLegendElement | null> = ref(null)
const auth = useAuth()
const resizercontent: Ref<HTMLLegendElement | null> = ref(null);
const queue = useQueue()
const modal = useModal()
const lyrics = useLyrics()
const router = useRouter()
const settings = useSettings()
useTracker()
const appcontent: Ref<HTMLLegendElement | null> = ref(null);
const auth = useAuth();
const queue = useQueue();
const modal = useModal();
const lyrics = useLyrics();
const router = useRouter();
const settings = useSettings();
useTracker();
handleShortcuts(useQueue, useModal)
handleShortcuts(useQueue, useModal);
router.afterEach(() => {
;(document.getElementById('acontent') as HTMLElement).scrollTo(0, 0)
})
(document.getElementById("acontent") as HTMLElement).scrollTo(0, 0);
});
onStartTyping(() => {
const elem = document.getElementById('globalsearch') as HTMLInputElement
elem.focus()
elem.value = ''
})
const elem = document.getElementById("globalsearch") as HTMLInputElement;
elem.focus();
elem.value = "";
});
function getContentSize() {
const elem = document.getElementById('acontent') as HTMLElement
const elem = document.getElementById("acontent") as HTMLElement;
return {
width: elem.offsetWidth,
height: elem.offsetHeight,
}
};
}
function updateContentElemSize({ width, height }: { width: number; height: number }) {
// 1572 is the maxwidth of the #acontent. see app-grid.scss > $maxwidth
const elem_width = appcontent.value?.offsetWidth || 0;
// 1572 is the maxwidth of the #acontent. see app-grid.scss > $maxwidth
const elem_width = appcontent.value?.offsetWidth || 0;
content_width.value = elem_width;
content_height.value = height;
content_width.value = elem_width;
content_height.value = height;
const elem_resizer_width = resizercontent.value?.offsetWidth || 0;
resizer_width.value = elem_resizer_width;
resizer_height.value = height;
updateCardWidth();
}
function handleWelcomeModal() {
let welcomeShowCount = readLocalStorage('shown-welcome-message')
if (!welcomeShowCount) {
welcomeShowCount = 0
}
if (welcomeShowCount < 2) {
modal.showWelcomeModal()
writeLocalStorage('shown-welcome-message', welcomeShowCount + 1)
}
resizer_width.value = elem_width;
updateCardWidth();
}
function handleRootDirsPrompt() {
getRootDirs().then((dirs) => {
getRootDirs().then(dirs => {
if (dirs.length === 0) {
modal.showRootDirsPromptModal()
modal.showRootDirsPromptModal();
} else {
settings.setRootDirs(dirs)
settings.setRootDirs(dirs);
}
})
});
}
onMounted(async () => {
const { width, height } = getContentSize()
updateContentElemSize({ width, height })
const { width, height } = getContentSize();
updateContentElemSize({ width, height });
const res = await getLoggedInUser()
const res = await getLoggedInUser();
if (res.status == 200) {
auth.setUser(res.data)
auth.setUser(res.data);
} else {
return
return;
}
handleWelcomeModal()
settings.initializeVolume()
settings.initializeVolume();
handleRootDirsPrompt()
handleRootDirsPrompt();
getAllSettings()
.then(({ settings: data }) => {
settings.mapDbSettings(data)
settings.mapDbSettings(data);
})
.then(() => {
if (queue.currenttrack && !settings.use_lyrics_plugin) {
lyrics.checkExists(
queue.currenttrack.filepath,
queue.currenttrack.trackhash
)
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash);
}
})
})
});
});
</script>
<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() {
this.applyClassBasedOnAgent();
},
methods: {
applyClassBasedOnAgent() {
const userAgent = navigator.userAgent;
const isWindows = /Win/.test(userAgent);
const isLinux = /Linux/.test(userAgent) && !/Android/.test(userAgent);
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor);
if ((isWindows || isLinux) && isChrome) {
document.documentElement.classList.add("designatedOS");
} else {
document.documentElement.classList.add("otherOS");
}
},
},
});
</script>
<style lang="scss">
@import './assets/scss/mixins.scss';
@import "./assets/scss/mixins.scss";
.designatedOS .r-sidebar {
&::-webkit-scrollbar {
display: none;

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">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83789 11.4512C5.83789 14.958 8.20215 17.3662 12.1836 17.3662H17.7383L19.7686 17.2783L18.2393 18.5703L16.0068 20.75C15.8311 20.9258 15.7168 21.1367 15.7168 21.4268C15.7168 21.9805 16.0947 22.3848 16.6748 22.3848C16.9209 22.3848 17.1934 22.2705 17.3779 22.0771L22.4229 17.1113C22.625 16.918 22.7305 16.6543 22.7305 16.3906C22.7305 16.1182 22.625 15.8545 22.4229 15.6611L17.3779 10.7041C17.1934 10.5107 16.9209 10.3965 16.6748 10.3965C16.0947 10.3965 15.7168 10.8008 15.7168 11.3457C15.7168 11.6357 15.8311 11.8555 16.0068 12.0312L18.2393 14.2021L19.7686 15.5029L17.7383 15.4062H12.1396C9.32715 15.4062 7.77148 13.8066 7.77148 11.5215C7.77148 9.24512 9.32715 7.5752 12.1396 7.5752H14.1963C14.7852 7.5752 15.1895 7.13574 15.1895 6.59082C15.1895 6.05469 14.7764 5.61523 14.1963 5.61523H12.0693C8.14941 5.61523 5.83789 7.93555 5.83789 11.4512Z" fill="#F2F2F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 971 B

After

Width:  |  Height:  |  Size: 948 B

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="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 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.10561 6.47363L1.47132 8.0962L6.75179 13.4597C7.3246 14.0367 7.84843 14.2774 8.43296 14.2774C9.0196 14.2774 9.57108 14.025 10.1162 13.4597L14.3022 9.13026C14.4034 9.0269 14.5206 8.97041 14.6101 8.97041C14.7093 8.97041 14.8265 9.0269 14.9277 9.12815L19.4795 13.766L17.6535 15.5962C17.1494 16.0983 17.4515 16.7766 18.1588 16.9605L25.0438 18.7181C25.6787 18.8883 26.2551 18.3354 26.0849 17.6887L24.321 10.792C24.1391 10.0868 23.447 9.78675 22.945 10.2888L21.1096 12.1359L16.2988 7.29745C15.7398 6.73636 15.2022 6.47972 14.608 6.47972C14.0214 6.47972 13.4603 6.73425 12.9248 7.29956L8.73882 11.6247C8.63757 11.7302 8.52999 11.7867 8.43085 11.7867C8.3296 11.7867 8.22413 11.7281 8.12288 11.6247L3.10561 6.47363Z" fill="currentColor"/>
<path d="M1 24.2688C1 24.8714 1.40242 25.2642 2.00711 25.2642H25.476C26.1182 25.2642 26.6383 24.7781 26.6383 24.1221C26.6383 23.4757 26.1182 22.9779 25.476 22.9779H3.61983C3.38428 22.9779 3.29592 22.8895 3.29592 22.654V4.21946C3.29592 3.58688 2.80022 3.0647 2.15382 3.0647C1.49781 3.0647 1 3.58688 1 4.21946V24.2688Z" 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="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 5.6875C5.55231 5.6875 4.375 6.86481 4.375 8.3125V19.6875C4.375 21.1352 5.55231 22.3125 7 22.3125H21C22.4477 22.3125 23.625 21.1352 23.625 19.6875V10.0625C23.625 8.61481 22.4477 7.4375 21 7.4375H12.7328C12.3369 7.4375 11.9492 7.30146 11.6407 7.05383L10.8914 6.45483C10.2732 5.96046 9.49659 5.6875 8.70471 5.6875H7ZM7 7.4375H8.70471C9.10065 7.4375 9.48873 7.57354 9.79761 7.82117L10.5461 8.42017C11.1643 8.91454 11.9409 9.1875 12.7328 9.1875H21C21.4826 9.1875 21.875 9.57994 21.875 10.0625V10.5H6.125V8.3125C6.125 7.82994 6.51744 7.4375 7 7.4375ZM6.125 12.25H21.875V19.6875C21.875 20.1701 21.4826 20.5625 21 20.5625H7C6.51744 20.5625 6.125 20.1701 6.125 19.6875V12.25ZM15.8705 13.3634L13.8214 13.7787C13.6705 13.8093 13.5625 13.9347 13.5625 14.0795V17.1086C13.5625 17.2556 13.4371 17.3717 13.025 17.4513C12.3867 17.5751 11.8125 17.8221 11.8125 18.5903C11.8125 18.9701 12.1575 19.4551 13.025 19.4551C13.7806 19.4551 14.4375 18.8381 14.4375 17.9631V15.6073C14.4375 15.5106 14.509 15.4271 14.6101 15.4065L15.9286 15.1382C16.0795 15.1076 16.1875 14.9822 16.1875 14.8374V13.6035C16.1875 13.4469 16.0337 13.3302 15.8705 13.3634Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

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" fill="currentColor" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<svg fill="currentColor" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path d="M5.09668 11.1846C5.09668 14.9375 8.25195 18.6465 13.1562 21.8105C13.4287 21.9863 13.7627 22.1621 13.9912 22.1621C14.2197 22.1621 14.5537 21.9863 14.8262 21.8105C19.7393 18.6465 22.8857 14.9375 22.8857 11.1846C22.8857 7.94141 20.6445 5.69141 17.7705 5.69141C16.0918 5.69141 14.7822 6.45605 13.9912 7.61621C13.2178 6.46484 11.8994 5.69141 10.2207 5.69141C7.33789 5.69141 5.09668 7.94141 5.09668 11.1846ZM6.90723 11.1758C6.90723 8.96094 8.36621 7.45801 10.3262 7.45801C11.9082 7.45801 12.7959 8.41602 13.3496 9.25098C13.5957 9.61133 13.7627 9.72559 13.9912 9.72559C14.2285 9.72559 14.3779 9.60254 14.6328 9.25098C15.2305 8.43359 16.083 7.45801 17.6562 7.45801C19.625 7.45801 21.084 8.96094 21.084 11.1758C21.084 14.2695 17.8672 17.6973 14.1582 20.1582C14.0791 20.2109 14.0264 20.2461 13.9912 20.2461C13.9561 20.2461 13.9033 20.2109 13.833 20.1582C10.124 17.6973 6.90723 14.2695 6.90723 11.1758Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1016 B

After

Width:  |  Height:  |  Size: 993 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

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.25098 13.2146C3.25098 13.6716 3.60254 14.0671 4.16504 14.0671C4.4375 14.0671 4.68359 13.9177 4.90332 13.7419L5.90527 12.8982V21.072C5.90527 22.3728 6.6875 23.1462 8.03223 23.1462H19.9238C21.2598 23.1462 22.0508 22.3728 22.0508 21.072V12.8542L23.1055 13.7419C23.3164 13.9177 23.5625 14.0671 23.835 14.0671C24.3535 14.0671 24.749 13.7419 24.749 13.2322C24.749 12.9333 24.6348 12.696 24.4062 12.5027L22.0508 10.5164V6.77222C22.0508 6.37671 21.7959 6.13062 21.4004 6.13062H20.1875C19.8008 6.13062 19.5371 6.37671 19.5371 6.77222V8.40698L15.2568 4.81226C14.4922 4.17065 13.5254 4.17065 12.7607 4.81226L3.60254 12.5027C3.36523 12.696 3.25098 12.9597 3.25098 13.2146ZM16.5312 15.6404C16.5312 15.2273 16.2676 14.9636 15.8545 14.9636H12.1631C11.75 14.9636 11.4775 15.2273 11.4775 15.6404V21.3972H8.49805C7.95312 21.3972 7.6543 21.0896 7.6543 20.5359V11.4304L13.6221 6.42065C13.8682 6.20972 14.1494 6.20972 14.3955 6.42065L20.293 11.3777V20.5359C20.293 21.0896 19.9941 21.3972 19.4492 21.3972H16.5312V15.6404Z" fill="#F2F2F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

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="M26.5355 20.4111L19.5935 13.8974C19.0683 13.4286 18.4589 13.1771 17.8324 13.1771C17.19 13.1771 16.6137 13.3977 16.0788 13.876L10.7862 18.6062L8.62453 16.6555C8.13234 16.2123 7.59117 15.9896 7.04249 15.9896C6.50039 15.9896 6.02507 16.2006 5.53288 16.6438L1.11023 20.6075C1.16788 22.7666 2.17217 23.8932 4.11232 23.8932H23.1117C25.4627 23.8932 26.6346 22.6801 26.5355 20.4111ZM9.0785 14.1538C10.6134 14.1538 11.8788 12.8883 11.8788 11.3396C11.8788 9.80465 10.6134 8.52754 9.0785 8.52754C7.52975 8.52754 6.26436 9.80465 6.26436 11.3396C6.26436 12.8883 7.52975 14.1538 9.0785 14.1538ZM3.84421 24.8781H23.9109C26.4499 24.8781 27.7552 23.5824 27.7552 21.0819V6.81004C27.7552 4.30739 26.4499 3.00427 23.9109 3.00427H3.84421C1.31484 3.00427 0 4.30739 0 6.81004V21.0819C0 23.5824 1.31484 24.8781 3.84421 24.8781ZM3.97733 22.5821C2.88772 22.5821 2.29592 22.018 2.29592 20.8794V7.01043C2.29592 5.87183 2.88772 5.30019 3.97733 5.30019H23.7778C24.8578 5.30019 25.4592 5.87183 25.4592 7.01043V20.8794C25.4592 22.018 24.8578 22.5821 23.7778 22.5821H3.97733Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 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,4 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.80577 26.3655H16.2891C18.8013 26.3655 20.0948 25.0602 20.0948 22.5287V17.2516H17.7989V22.386C17.7989 23.4777 17.2369 24.0695 16.0983 24.0695H3.99866C2.85795 24.0695 2.29592 23.4777 2.29592 22.386V4.98905C2.29592 3.89733 2.85795 3.29592 3.99866 3.29592H16.0983C17.2369 3.29592 17.7989 3.89733 17.7989 4.98905V10.1287H20.0948V4.84632C20.0948 2.32445 18.8013 1 16.2891 1H3.80577C1.29562 1 0 2.32445 0 4.84632V22.5287C0 25.0602 1.29562 26.3655 3.80577 26.3655Z" fill="currentColor"/>
<path d="M11.3524 14.7604H23.1619L24.907 14.6732L24.0476 15.4047L22.3001 17.0455C22.0894 17.2328 21.9787 17.5042 21.9787 17.7669C21.9787 18.3055 22.3629 18.7384 22.9108 18.7384C23.1907 18.7384 23.4014 18.632 23.6058 18.4351L27.3965 14.5016C27.671 14.223 27.7603 13.9614 27.7603 13.6806C27.7603 13.3902 27.671 13.1362 27.3965 12.8596L23.6058 8.92399C23.4014 8.72712 23.1907 8.6132 22.9108 8.6132C22.3629 8.6132 21.9787 9.03438 21.9787 9.57298C21.9787 9.84532 22.0894 10.1188 22.3001 10.3061L24.0476 11.9566L24.907 12.688L23.1619 12.5913H11.3524C10.7801 12.5913 10.2961 13.086 10.2961 13.6806C10.2961 14.2752 10.7801 14.7604 11.3524 14.7604Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.2959 22.2337V5.40713H19.6043V22.2337H8.2959ZM13.9666 25.3457C13.364 25.3457 12.8754 24.857 12.8754 24.2523C12.8754 23.6615 13.364 23.1749 13.9666 23.1749C14.5575 23.1749 15.0461 23.6615 15.0461 24.2523C15.0461 24.857 14.5575 25.3457 13.9666 25.3457ZM11.5743 3.27433C11.5743 2.97316 11.785 2.77417 12.0745 2.77417H15.8332C16.1247 2.77417 16.3354 2.97316 16.3354 3.27433C16.3354 3.57761 16.1247 3.76276 15.8332 3.76276H12.0745C11.785 3.76276 11.5743 3.57761 11.5743 3.27433Z" fill="transparent"/>
<path d="M6 23.4104C6 25.3483 7.35117 26.6408 9.37101 26.6408H18.5798C20.5701 26.6408 21.9002 25.3483 21.9002 23.4083V4.23249C21.9002 2.29258 20.5701 1 18.5798 1H9.37101C7.35117 1 6 2.29258 6 4.23038V23.4104ZM8.29592 22.2337V5.40717H19.6043V22.2337H8.29592ZM13.9666 25.3457C13.364 25.3457 12.8754 24.857 12.8754 24.2523C12.8754 23.6615 13.364 23.1749 13.9666 23.1749C14.5575 23.1749 15.0462 23.6615 15.0462 24.2523C15.0462 24.857 14.5575 25.3457 13.9666 25.3457ZM11.5744 3.27437C11.5744 2.9732 11.7851 2.77421 12.0745 2.77421H15.8332C16.1247 2.77421 16.3354 2.9732 16.3354 3.27437C16.3354 3.57765 16.1247 3.7628 15.8332 3.7628H12.0745C11.7851 3.7628 11.5744 3.57765 11.5744 3.27437Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 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="M9.3418 21.3711C9.71094 21.3711 10.0361 21.2393 10.4404 21.002L20.8203 14.999C21.5762 14.5596 21.8926 14.2168 21.8926 13.6631C21.8926 13.1094 21.5762 12.7754 20.8203 12.3271L10.4404 6.32422C10.0361 6.08691 9.71094 5.95508 9.3418 5.95508C8.62109 5.95508 8.11133 6.50879 8.11133 7.37891V19.9473C8.11133 20.8262 8.62109 21.3711 9.3418 21.3711Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 484 B

After

Width:  |  Height:  |  Size: 461 B

View File

@@ -1,3 +1,3 @@
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.16016 9.50586C6.85449 9.50586 7.4082 8.95215 7.4082 8.25781C7.4082 7.57227 6.85449 7.00977 6.16016 7.00977C5.47461 7.00977 4.91211 7.57227 4.91211 8.25781C4.91211 8.95215 5.47461 9.50586 6.16016 9.50586ZM10.291 9.10156H22.2266C22.7012 9.10156 23.0791 8.73242 23.0791 8.25781C23.0791 7.7832 22.71 7.41406 22.2266 7.41406H10.291C9.8252 7.41406 9.44727 7.7832 9.44727 8.25781C9.44727 8.73242 9.81641 9.10156 10.291 9.10156ZM6.16016 14.9111C6.85449 14.9111 7.4082 14.3574 7.4082 13.6631C7.4082 12.9775 6.85449 12.415 6.16016 12.415C5.47461 12.415 4.91211 12.9775 4.91211 13.6631C4.91211 14.3574 5.47461 14.9111 6.16016 14.9111ZM10.291 14.5068H22.2266C22.7012 14.5068 23.0791 14.1377 23.0791 13.6631C23.0791 13.1885 22.71 12.8193 22.2266 12.8193H10.291C9.8252 12.8193 9.44727 13.1885 9.44727 13.6631C9.44727 14.1377 9.81641 14.5068 10.291 14.5068ZM6.16016 20.3164C6.85449 20.3164 7.4082 19.7627 7.4082 19.0684C7.4082 18.3828 6.85449 17.8203 6.16016 17.8203C5.47461 17.8203 4.91211 18.3828 4.91211 19.0684C4.91211 19.7627 5.47461 20.3164 6.16016 20.3164ZM10.291 19.9121H22.2266C22.7012 19.9121 23.0791 19.543 23.0791 19.0684C23.0791 18.5938 22.71 18.2246 22.2266 18.2246H10.291C9.8252 18.2246 9.44727 18.5938 9.44727 19.0684C9.44727 19.543 9.81641 19.9121 10.291 19.9121Z" fill="#F2F2F2"/>
<path d="M6.16016 9.50586C6.85449 9.50586 7.4082 8.95215 7.4082 8.25781C7.4082 7.57227 6.85449 7.00977 6.16016 7.00977C5.47461 7.00977 4.91211 7.57227 4.91211 8.25781C4.91211 8.95215 5.47461 9.50586 6.16016 9.50586ZM10.291 9.10156H22.2266C22.7012 9.10156 23.0791 8.73242 23.0791 8.25781C23.0791 7.7832 22.71 7.41406 22.2266 7.41406H10.291C9.8252 7.41406 9.44727 7.7832 9.44727 8.25781C9.44727 8.73242 9.81641 9.10156 10.291 9.10156ZM6.16016 14.9111C6.85449 14.9111 7.4082 14.3574 7.4082 13.6631C7.4082 12.9775 6.85449 12.415 6.16016 12.415C5.47461 12.415 4.91211 12.9775 4.91211 13.6631C4.91211 14.3574 5.47461 14.9111 6.16016 14.9111ZM10.291 14.5068H22.2266C22.7012 14.5068 23.0791 14.1377 23.0791 13.6631C23.0791 13.1885 22.71 12.8193 22.2266 12.8193H10.291C9.8252 12.8193 9.44727 13.1885 9.44727 13.6631C9.44727 14.1377 9.81641 14.5068 10.291 14.5068ZM6.16016 20.3164C6.85449 20.3164 7.4082 19.7627 7.4082 19.0684C7.4082 18.3828 6.85449 17.8203 6.16016 17.8203C5.47461 17.8203 4.91211 18.3828 4.91211 19.0684C4.91211 19.7627 5.47461 20.3164 6.16016 20.3164ZM10.291 19.9121H22.2266C22.7012 19.9121 23.0791 19.543 23.0791 19.0684C23.0791 18.5938 22.71 18.2246 22.2266 18.2246H10.291C9.8252 18.2246 9.44727 18.5938 9.44727 19.0684C9.44727 19.543 9.81641 19.9121 10.291 19.9121Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,4 +1,4 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="28px" height="28px"
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"
baseProfile="basic">
<path d="M51,48H25c-1.104,0-2-0.896-2-2s0.896-2,2-2h26c1.104,0,2,0.896,2,2S52.104,48,51,48z" />
<path

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 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="M4 15.007C4 20.8158 8.66992 25.4899 14.4712 25.4899C20.2703 25.4899 24.9306 20.8158 24.9306 15.007C24.9306 14.318 24.4478 13.8277 23.7597 13.8277C23.0929 13.8277 22.641 14.318 22.641 15.007C22.641 19.5471 19.0038 23.194 14.4712 23.194C9.93647 23.194 6.29592 19.5471 6.29592 15.007C6.29592 10.4428 9.90694 6.81608 14.4353 6.81608C15.2015 6.81608 15.9196 6.87584 16.5203 7.01038L13.3265 10.1691C13.1233 10.384 13.0042 10.6437 13.0042 10.9488C13.0042 11.5912 13.4913 12.0762 14.1241 12.0762C14.4592 12.0762 14.7262 11.9676 14.924 11.7581L19.7373 6.90866C19.9862 6.66936 20.0915 6.38858 20.0915 6.06421C20.0915 5.75578 19.967 5.44945 19.7373 5.22187L14.924 0.333982C14.7241 0.110624 14.4496 0 14.1241 0C13.4913 0 13.0042 0.508356 13.0042 1.15288C13.0042 1.45804 13.1233 1.71772 13.3169 1.93264L16.1514 4.72992C15.6327 4.63172 15.0426 4.56445 14.4353 4.56445C8.64039 4.56445 4 9.20367 4 15.007Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1010 B

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">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5322 19.0332C13.9297 19.0332 15.2393 18.6113 16.3291 17.8906L20.1787 21.749C20.4336 21.9951 20.7588 22.1182 21.1104 22.1182C21.8398 22.1182 22.376 21.5469 22.376 20.8262C22.376 20.4922 22.2617 20.167 22.0156 19.9209L18.1924 16.0801C18.9834 14.9551 19.4492 13.5928 19.4492 12.1162C19.4492 8.31055 16.3379 5.19922 12.5322 5.19922C8.73535 5.19922 5.61523 8.31055 5.61523 12.1162C5.61523 15.9219 8.72656 19.0332 12.5322 19.0332ZM12.5322 17.1875C9.74609 17.1875 7.46094 14.9023 7.46094 12.1162C7.46094 9.33008 9.74609 7.04492 12.5322 7.04492C15.3184 7.04492 17.6035 9.33008 17.6035 12.1162C17.6035 14.9023 15.3184 17.1875 12.5322 17.1875Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 750 B

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.1035 23.208H14.8877C15.6172 23.208 16.1885 22.751 16.3643 22.0479L16.7158 20.5098L16.9443 20.4219L18.2891 21.2568C18.9043 21.6436 19.6338 21.5381 20.1523 21.0195L21.3828 19.7891C21.9102 19.2617 21.998 18.541 21.6113 17.9346L20.7764 16.5898L20.8643 16.3789L22.4023 16.0186C23.0967 15.8428 23.5537 15.2715 23.5537 14.542V12.8105C23.5537 12.0811 23.1055 11.5098 22.4023 11.334L20.873 10.9648L20.7852 10.7363L21.6201 9.40039C22.0068 8.79395 21.9189 8.07324 21.3916 7.53711L20.1611 6.30664C19.6514 5.78809 18.9219 5.69141 18.3066 6.06934L16.9619 6.89551L16.7158 6.80762L16.3643 5.26074C16.1885 4.55762 15.6172 4.10938 14.8877 4.10938H13.1035C12.3652 4.10938 11.7939 4.55762 11.627 5.26074L11.2754 6.80762L11.0293 6.89551L9.68457 6.06934C9.06055 5.69141 8.33984 5.78809 7.83008 6.30664L6.59082 7.53711C6.07227 8.07324 5.97559 8.79395 6.3623 9.40039L7.19727 10.7363L7.10938 10.9648L5.58887 11.334C4.88574 11.5098 4.4375 12.0811 4.4375 12.8105V14.542C4.4375 15.2715 4.89453 15.8428 5.58887 16.0186L7.12695 16.3789L7.20605 16.5898L6.37109 17.9346C5.98438 18.541 6.08105 19.2617 6.59961 19.7891L7.83887 21.0195C8.34863 21.5381 9.07812 21.6436 9.69336 21.2568L11.0381 20.4219L11.2754 20.5098L11.627 22.0479C11.7939 22.751 12.3652 23.208 13.1035 23.208ZM13.332 21.5908C13.1826 21.5908 13.1035 21.5293 13.0859 21.3975L12.5586 19.2354C12.0049 19.1035 11.4688 18.875 11.0381 18.6025L9.13965 19.7715C9.02539 19.8418 8.91992 19.833 8.81445 19.7275L7.8916 18.8047C7.78613 18.708 7.78613 18.6025 7.85645 18.4883L9.02539 16.5898C8.7793 16.168 8.55078 15.6406 8.41895 15.0869L6.24805 14.5684C6.11621 14.5508 6.0459 14.4717 6.0459 14.3223V13.0215C6.0459 12.8633 6.10742 12.8018 6.24805 12.7666L8.41016 12.2568C8.54199 11.668 8.79688 11.123 9.0166 10.7275L7.84766 8.84668C7.77734 8.72363 7.77734 8.61816 7.87402 8.5127L8.80566 7.59863C8.91113 7.50195 9.00781 7.48438 9.13965 7.56348L11.0205 8.71484C11.416 8.46875 12.0049 8.22266 12.5674 8.08203L13.0859 5.91992C13.1035 5.78809 13.1826 5.71777 13.332 5.71777H14.6592C14.8086 5.71777 14.8789 5.7793 14.9053 5.91992L15.4326 8.09082C16.0039 8.23145 16.5225 8.46875 16.9619 8.71484L18.8428 7.56348C18.9746 7.49316 19.0713 7.50195 19.1768 7.60742L20.1084 8.52148C20.2139 8.61816 20.2139 8.72363 20.1348 8.84668L18.9746 10.7275C19.1855 11.123 19.4492 11.668 19.5811 12.2568L21.7432 12.7666C21.8838 12.8018 21.9365 12.8633 21.9365 13.0215V14.3223C21.9365 14.4717 21.875 14.5508 21.7432 14.5684L19.5723 15.0869C19.4404 15.6406 19.2031 16.1768 18.957 16.5898L20.126 18.4795C20.1963 18.6025 20.1963 18.6992 20.0908 18.7959L19.168 19.7275C19.0625 19.833 18.957 19.8418 18.8428 19.7715L16.9531 18.6025C16.5137 18.875 16.0127 19.0947 15.4326 19.2354L14.9053 21.3975C14.8789 21.5293 14.8086 21.5908 14.6592 21.5908H13.332ZM14 16.9941C15.8281 16.9941 17.3311 15.4912 17.3311 13.6543C17.3311 11.835 15.8281 10.332 14 10.332C12.1631 10.332 10.6514 11.835 10.6514 13.6543C10.6514 15.4912 12.1631 16.9941 14 16.9941ZM14 15.4736C12.998 15.4736 12.1807 14.6562 12.1807 13.6543C12.1807 12.6699 13.0068 11.8525 14 11.8525C14.9756 11.8525 15.793 12.6699 15.793 13.6543C15.793 14.6475 14.9756 15.4736 14 15.4736Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 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

@@ -44,13 +44,13 @@ $g-border: solid 1px $gray5;
padding-right: $padright;
@include allPhones {
display: flex;
gap: $small;
height: unset;
padding: 6px 8px;
margin: $medium 1rem;
border-radius: 1rem;
border: solid 1px $gray5;
background-image: linear-gradient(37deg, rgb(29, 28, 28), transparent);
border-radius: 5rem;
background-color: $gray;
}
}
@@ -231,7 +231,13 @@ $g-border: solid 1px $gray5;
.isSmall {
.songlist-item {
grid-template-columns: 2fr 5.5rem !important;
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;
@@ -257,7 +263,7 @@ $g-border: solid 1px $gray5;
.isMedium {
// hide album column
.songlist-item {
grid-template-columns: 1.75rem 1.5fr 1fr 5.5rem;
grid-template-columns: 1.75rem 1.5fr 1fr 7.5rem;
}
.song-album {

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%;

View File

@@ -1,88 +1,88 @@
@import "./app-grid.scss", "./controls.scss", "./inputs.scss", "./scrollbars.scss", "./state.scss", "./variants.scss",
"./basic.scss", "./search-tabheaders.scss", "./album-grid.scss";
"./basic.scss", "./search-tabheaders.scss", "./album-grid.scss";
* {
box-sizing: border-box;
box-sizing: border-box;
}
#vue-recycle-scroller__item-wrapper {
overflow: visible !important;
overflow: visible !important;
}
html {
cursor: default !important;
overflow: hidden;
color: $white;
background-color: $body;
cursor: default !important;
overflow: hidden;
color: $white;
background-color: $body;
& > * {
overflow: visible !important;
-webkit-tap-highlight-color: transparent; /* Webkit browsers like Safari */
tap-highlight-color: transparent; /* Some Android browsers */
outline: none;
}
& > * {
overflow: visible !important;
-webkit-tap-highlight-color: transparent; /* Webkit browsers like Safari */
tap-highlight-color: transparent; /* Some Android browsers */
outline: none;
}
}
html.loading,
html.loading * {
cursor: progress !important;
cursor: progress !important;
}
body {
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-size: 1rem;
font-weight: 400;
color: $white;
image-rendering: -webkit-optimize-contrast;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
height: 100vh;
width: 100vw;
overflow: hidden;
margin: 0;
background-color: $body;
color-scheme: dark;
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-size: 1rem;
font-weight: 400;
color: $white;
image-rendering: -webkit-optimize-contrast;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
height: 100vh;
width: 100vw;
overflow: hidden;
margin: 0;
background-color: $body;
color-scheme: dark;
#app {
width: 100%;
height: 100%;
}
#app {
width: 100%;
height: 100%;
}
}
.noSelect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.dimmer {
display: none;
display: none;
}
@include allPhones {
.dimmer {
display: block;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
overflow: visible;
opacity: 0;
visibility: hidden;
background-color: rgb(0, 0, 0, 0.5);
transition: opacity 300ms ease, visibility 300ms ease;
}
.dimmer {
display: block;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
overflow: visible;
opacity: 0;
visibility: hidden;
background-color: rgb(0, 0, 0, 0.6);
transition: opacity 300ms ease, visibility 300ms ease;
}
.dimmer.active {
opacity: 1;
visibility: visible;
}
.dimmer.active {
opacity: 1;
visibility: visible;
}
}

View File

@@ -1,58 +1,84 @@
/* Total width */
.designatedOS ::-webkit-scrollbar {
background-color: $body;
width: 14px;
background-color: $body;
width: 12px;
}
/* Background of the scrollbar except button or resizer */
.designatedOS ::-webkit-scrollbar-track {
background-color: $body;
background-color: $body;
}
/* Scrollbar itself */
.designatedOS ::-webkit-scrollbar-thumb {
background-color: $gray2;
border-radius: 16px;
border: 4px solid $body;
background-color: $gray2;
border-radius: 16px;
border: 3px solid $body;
}
.designatedOS ::-webkit-scrollbar-thumb:hover {
background-color: $gray1;
background-color: $gray1;
}
/* Custom scrollbar version for dropdowns etc */
/* Context dropdown menus */
.designatedOS .context-item .children > .wrapper::-webkit-scrollbar {
width: 6px !important;
background-color: transparent !important;
width: 6px !important;
background-color: transparent !important;
}
.designatedOS .context-item .children > .wrapper::-webkit-scrollbar-track {
background-color: transparent !important;
background-color: transparent !important;
}
.designatedOS .context-item .children > .wrapper::-webkit-scrollbar-thumb {
border: none !important;
border: none !important;
}
.designatedOS .context-item .children > .wrapper::-webkit-scrollbar-thumb:hover {
border: none !important;
border: none !important;
}
/* Scrollable divs */
.designatedOS .scrollable::-webkit-scrollbar {
width: 6px !important;
background-color: transparent !important;
width: 6px !important;
background-color: transparent !important;
}
.designatedOS .scrollable::-webkit-scrollbar-track {
background-color: transparent !important;
background-color: transparent !important;
}
.designatedOS .scrollable::-webkit-scrollbar-thumb {
border: none !important;
border: none !important;
}
.designatedOS .scrollable::-webkit-scrollbar-thumb:hover {
border: none !important;
border: none !important;
}
/* Settings modal */
.designatedOS .settingssidebar::-webkit-scrollbar-track {
background-color: $gray;
}
.designatedOS .settingssidebar::-webkit-scrollbar-thumb {
border-color: $gray;
}
.designatedOS .settingsmodalcontent::-webkit-scrollbar-track {
background-color: $black;
}
.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

@@ -22,7 +22,7 @@
@for $i from 1 through 9 {
#Line_#{$i} {
animation: pulse 0.4s infinite;
animation: pulse 0.6s infinite;
animation-delay: $i * 0.12s;
transform: scaleY(0.8);
transform-origin: center;

View File

@@ -1,78 +1,78 @@
input[type="range"] {
-webkit-appearance: none;
appearance: none;
margin-right: 15px;
width: calc(100% - 2px);
height: 0.3rem;
border-radius: 5px;
background: $gray4 linear-gradient(37deg, white, white) no-repeat;
background-size: 100% 100%;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: 0;
width: 0.8rem;
border-radius: 50%;
background: white;
}
&::-moz-range-thumb {
input[type='range'] {
-webkit-appearance: none;
appearance: none;
margin-right: 15px;
width: calc(100% - 2px);
height: 0.3rem;
border-radius: 5px;
background: $gray4;
background-size: 100% 100%;
cursor: pointer;
height: 0;
border-radius: 50%;
background: white;
border: none;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
&::-ms-thumb {
-webkit-appearance: none;
appearance: none;
height: 0;
width: 0.8rem;
border-radius: 50%;
background: white;
}
height: 0;
width: 0.8rem;
border-radius: 50%;
background: white;
border: none;
}
&::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
height: 0;
border-radius: 50%;
background: white;
border: none;
}
&::-ms-thumb {
-webkit-appearance: none;
appearance: none;
height: 0;
width: 0.8rem;
border-radius: 50%;
background: white;
border: none;
}
}
/* Input Thumb */
input[type="range"]::-webkit-slider-thumb:hover {
background: $accent;
input[type='range']::-webkit-slider-thumb:hover {
background: $accent;
}
input[type="range"]::-moz-range-thumb:hover {
background: $accent;
input[type='range']::-moz-range-thumb:hover {
background: $accent;
}
input[type="range"]::-ms-thumb:hover {
background: $accent;
input[type='range']::-ms-thumb:hover {
background: $accent;
}
/* Input Track */
input[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
input[type='range']::-webkit-slider-runnable-track {
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
}
input[type="range"]::-moz-range-track {
appearance: none;
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
input[type='range']::-moz-range-track {
appearance: none;
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
}
input[type="range"]::-ms-track {
appearance: none;
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
input[type='range']::-ms-track {
appearance: none;
-webkit-appearance: none;
box-shadow: none;
border: none;
background: transparent;
}

View File

@@ -22,22 +22,22 @@ $black: #181a1c;
$white: #ffffffde;
$gray: #1c1c1e;
$gray1: rgb(142, 142, 147);
$gray2: rgb(99, 99, 102);
$gray3: rgb(72, 72, 74);
$gray4: rgb(58, 58, 60);
$gray5: rgb(44, 44, 46);
$gray1: #8e8e93;
$gray2: #636366;
$gray3: #48484a;
$gray4: #3a3a3c;
$gray5: #2c2c2e;
$body: #111111;
$red: #ff453a;
$red: #f7635c;
$blue: #0a84ff;
$darkblue: #055ee2;
$green: rgb(94, 247, 132);
$green: #5ef784;
$yellow: rgb(255, 214, 10);
$orange: #ff9f0a;
$pink: rgb(255, 55, 95);
$purple: #bf5af2;
$brown: rgb(172, 142, 104);
$brown: #ac8e68;
$indigo: #5e5ce6;
$teal: rgb(64, 200, 224);

View File

@@ -1,35 +1,65 @@
<template>
<div v-if="album_disc.is_album_disc_number" class="album_disc_header no-select">
<div class="disc_number">Disc {{ album_disc.album_page_disc_number }}</div>
<div></div>
</div>
<div v-if="album_disc.is_album_disc_number" class="album_disc_header no-select">
<div class="disc_number">
Disc {{ album_disc.album_page_disc_number }}
<span @click="$emit('playDisc', album_disc.album_page_disc_number || 0)" class="play">
<PlaySvg /> Play Disc {{ album_disc.album_page_disc_number }}</span
>
</div>
<div class="play"></div>
</div>
</template>
<script setup lang="ts">
import { AlbumDisc } from "@/interfaces";
import PlaySvg from '@/assets/icons/play.svg'
import { AlbumDisc } from '@/interfaces'
defineProps<{
album_disc: AlbumDisc;
}>();
album_disc: AlbumDisc
}>()
defineEmits<{
(e: 'playDisc', disc_number: number): void
}>()
</script>
<style lang="scss">
.album_disc_header {
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
padding-left: 1rem;
margin-top: $small;
height: $song-item-height;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
padding-left: 1rem;
margin-top: $small;
height: $song-item-height;
.disc_number {
font-size: $medium;
font-weight: 500;
opacity: 0.75;
}
.disc_number {
font-size: $medium;
font-weight: 500;
opacity: 0.75;
display: flex;
}
@include largePhones {
padding-left: 0.5rem !important;
}
.play {
margin-left: $small;
opacity: 0;
cursor: pointer;
display: flex;
align-items: center;
transition: opacity 0.2s ease-out;
svg {
height: 12px;
}
}
@include largePhones {
padding-left: 0.5rem !important;
}
&:hover {
.play {
opacity: 1;
}
}
}
</style>

View File

@@ -11,13 +11,13 @@
</div>
<div
v-for="genre in genres"
:key="genre"
:key="genre.genrehash"
class="genre-pill rounded pad-sm"
:style="{
backgroundColor: color,
}"
>
{{ genre }}
{{ genre.name }}
</div>
</div>
</div>
@@ -38,7 +38,7 @@ const props = defineProps<{
}>();
const genres = computed(() => {
return props.source === "album" ? album.info.genres : store.genres;
return props.source === "album" ? album.info.genres : store.info.genres;
});
const color = computed(() => {

View File

@@ -1,21 +0,0 @@
<template>
<div v-if="!isSmallPhone" v-auto-animate class="albumtype">
<span v-if="album.is_soundtrack">Soundtrack</span>
<span v-else-if="album.is_live">Concert</span>
<span v-else-if="album.is_compilation">Compilation</span>
<span v-else-if="album.is_EP">EP</span>
<span v-else-if="album.is_single">Single</span>
<span v-else>Album</span>
</div>
</template>
<script setup lang="ts">
import { Album } from "@/interfaces";
import { isSmallPhone } from "@/stores/content-width";
defineProps<{
album: Album;
}>();
</script>
<style lang="scss"></style>

View File

@@ -1,7 +1,8 @@
<template>
<div class="album-info" :style="{ color: textColor }">
<div class="top">
<AlbumType :album="album" />
<!-- <AlbumType :album="album" /> -->
<div class="albumtype">{{ album.type }}</div>
<div id="albumheadertitle" class="title ellip2">
<span v-for="t in titleSplits" :key="t">{{ t }}<br /></span>
</div>
@@ -72,6 +73,7 @@ onBeforeRouteUpdate(() => {
.albumtype {
font-size: 14px;
font-weight: 700;
text-transform: capitalize;
}
.title {

View File

@@ -6,10 +6,11 @@
:albumartists="''"
:small="true"
:append="!isSmallPhone ? statsText : ''"
:prepend="isSmallPhone ? 'Album by ' : ''"
/>
</div>
<div v-if="isSmallPhone" class="stats2">
{{ album.date }} {{ !album.is_single ? ` ${album.count} Tracks` : "" }}
{{ new Date(album.date * 1000).getFullYear() }} {{ !album.is_single ? ` ${album.trackcount} Tracks` : "" }}
{{ formatSeconds(album.duration, true) }}
</div>
</div>
@@ -32,8 +33,8 @@ const statsText = computed(() => {
const is_single = props.album.is_single;
// hide track count if it's a single, also add an s to track if it's plural
return `${props.album.date} ${
!is_single ? `${props.album.count.toLocaleString()} Track${props.album.count > 1 ? "s" : ""}` : ""
return `${new Date(props.album.date * 1000).getFullYear()} ${
!is_single ? `${props.album.trackcount.toLocaleString()} Track${props.album.trackcount > 1 ? "s" : ""}` : ""
}${formatSeconds(props.album.duration, true)}`;
});
</script>

View File

@@ -1,150 +1,161 @@
<template>
<div
class="album-header-ambient rounded-lg"
style="height: 100%; width: 100%"
:style="{
boxShadow: colors.bg ? `0 .5rem 2rem ${colors.bg}` : '0 .5rem 2rem black',
}"
></div>
<div
ref="albumheaderthing"
class="a-header rounded-lg"
:style="{
background: 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="album-header-ambient rounded-lg"
style="height: 100%; width: 100%"
:style="{
boxShadow:
// hide shadow on small screen
isSmallPhone ? '' : colors.bg
? `0 .5rem 2rem ${colors.bg}`
: '0 .5rem 2rem black',
}"
></div>
<div
ref="albumheaderthing"
class="a-header rounded-lg"
:style="{
// hide background on small screen
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>
<Info />
</div>
<Info />
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { paths } from "@/config";
import { isHeaderSmall } from "@/stores/content-width";
import { paths } from '@/config'
import { isHeaderSmall, isSmallPhone } from '@/stores/content-width'
import useNavStore from "@/stores/nav";
import useAlbumStore from "@/stores/pages/album";
import useNavStore from '@/stores/nav'
import useAlbumStore from '@/stores/pages/album'
import Info from "@/components/AlbumView/Header/Info.vue";
import useVisibility from "@/utils/useVisibility";
import Info from '@/components/AlbumView/Header/Info.vue'
import useVisibility from '@/utils/useVisibility'
const albumheaderthing = ref<any>(null);
const imguri = paths.images;
const albumheaderthing = ref<any>(null)
const imguri = paths.images
const nav = useNavStore();
const store = useAlbumStore();
const nav = useNavStore()
const store = useAlbumStore()
const { info: album, colors } = storeToRefs(store);
const { info: album, colors } = storeToRefs(store)
defineEmits<{
// eslint-disable-next-line no-unused-vars
(event: "playThis"): void;
}>();
// eslint-disable-next-line no-unused-vars
(event: 'playThis'): void
}>()
function handleVisibilityState(state: boolean) {
nav.toggleShowPlay(state);
nav.toggleShowPlay(state)
}
useVisibility(albumheaderthing, handleVisibilityState);
useVisibility(albumheaderthing, handleVisibilityState)
</script>
<style lang="scss">
.balance-text-temp {
visibility: hidden;
position: absolute;
top: -9999px;
left: -9999px;
visibility: hidden;
position: absolute;
top: -9999px;
left: -9999px;
}
.album-header-ambient {
width: 20rem;
position: absolute;
z-index: -100 !important;
opacity: 0.25;
width: 20rem;
position: absolute;
z-index: -100 !important;
opacity: 0.25;
}
.a-header {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
padding: 1rem;
height: $banner-height;
background-color: $black;
align-items: flex-end;
.big-img {
height: 16rem;
display: flex;
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
padding: 1rem;
height: $banner-height;
// background-color: $black;
align-items: flex-end;
img {
height: 16rem;
max-width: 16rem;
object-fit: contain;
}
}
.big-img.imgSmall {
width: 12rem;
height: 12rem;
img {
height: 12rem;
}
}
.nocontrast {
color: $black;
.top {
.albumtype {
color: $pink;
}
}
}
@include largePhones {
grid-template-columns: 1fr;
padding: 2rem 1rem;
height: 25rem;
.big-img {
width: 10rem !important;
height: 10rem !important;
aspect-ratio: 1;
margin: 0 auto;
height: 16rem;
display: flex;
align-items: flex-end;
img {
height: 10rem !important;
}
img {
height: 16rem;
max-width: 16rem;
object-fit: contain;
}
}
.title {
font-size: 1.5rem !important;
max-width: fit-content;
margin: 0 auto;
text-align: center;
.big-img.imgSmall {
width: 12rem;
height: 12rem;
img {
height: 12rem;
}
}
.album-buttons {
justify-content: center;
.nocontrast {
color: $black;
.top {
.albumtype {
color: $pink;
}
}
}
.album-stats > div {
border: none;
margin: $small auto;
}
@include largePhones {
grid-template-columns: 1fr;
padding: 2rem 1rem;
height: 25rem;
.versions {
margin-bottom: 0 !important;
margin-left: 0 !important;
text-align: center;
.big-img {
width: 14rem !important;
height: 14rem !important;
aspect-ratio: 1;
margin: 0 auto;
img {
height: 14rem !important;
}
}
.title {
font-size: 1.5rem !important;
max-width: fit-content;
margin: 0 auto;
text-align: center;
}
.album-buttons {
justify-content: center;
}
.album-stats > div {
border: none;
margin: $small auto;
}
.versions {
margin-bottom: 0 !important;
margin-left: 0 !important;
text-align: center;
}
}
}
}
</style>

View File

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

View File

@@ -5,8 +5,8 @@
:state="artist.info.is_favorite"
:color="
!useCircularImage
? artist.info.colors[0]
? artist.info.colors[0]
? artist.info.color
? artist.info.color
: ''
: ''
"
@@ -26,8 +26,8 @@
import { ref } from "vue";
import { favType, playSources } from "@/enums";
import favoriteHandler from "@/helpers/favoriteHandler";
import { showArtistContextMenu } from "@/helpers/contextMenuHandler";
import favoriteHandler from "@/helpers/favoriteHandler";
import useArtistPageStore from "@/stores/pages/artist";
import MoreSvg from "@/assets/icons/more.svg";

View File

@@ -1,78 +1,77 @@
<template>
<div
class="artist-info"
:style="{
color: !useCircularImage ? (artist.colors[0] ? getTextColor(artist.colors[0]) : undefined) : undefined,
}"
>
<section class="text">
<div class="card-title">Artist</div>
<div class="artist-name" :class="`${useCircularImage ? 'ellip' : 'ellip2'}`" :title="artist.name">
{{ artist.name }}
</div>
<div class="stats">
<span v-if="artist.trackcount">
{{ artist.trackcount.toLocaleString() }} Track{{ `${artist.trackcount == 1 ? "" : "s"}` }}
</span>
{{ artist.albumcount && artist.trackcount.toLocaleString() ? "" : "" }}
<span v-if="artist.albumcount">
{{ artist.albumcount.toLocaleString() }} Album{{ `${artist.albumcount == 1 ? "" : "s"}` }}
</span>
<span v-if="artist.duration">
{{ ` ${formatSeconds(artist.duration, true)}` }}
</span>
</div>
</section>
<Buttons :use-circular-image="useCircularImage" />
</div>
<div
class="artist-info"
:style="{
color: !useCircularImage ? (artist.color ? getTextColor(artist.color) : undefined) : undefined,
}"
>
<section class="text">
<div class="card-title">Artist</div>
<div class="artist-name" :class="`${useCircularImage ? 'ellip' : 'ellip2'}`" :title="artist.name">
{{ artist.name }}
</div>
<div class="stats">
<span v-if="artist.trackcount">
{{ artist.trackcount.toLocaleString() }} Track{{ `${artist.trackcount == 1 ? '' : 's'} ` }}
</span>
<span v-if="artist.albumcount">
{{ artist.albumcount.toLocaleString() }} Album{{ `${artist.albumcount == 1 ? '' : 's'} ` }}
</span>
<span v-if="artist.duration">
{{ `${formatSeconds(artist.duration, true)}` }}
</span>
</div>
</section>
<Buttons :use-circular-image="useCircularImage" />
</div>
</template>
<script setup lang="ts">
import { getTextColor } from "@/utils/colortools/shift";
import { getTextColor } from '@/utils/colortools/shift'
import { Artist } from "@/interfaces";
import formatSeconds from "@/utils/useFormatSeconds";
import Buttons from "./Buttons.vue";
import { Artist } from '@/interfaces'
import formatSeconds from '@/utils/useFormatSeconds'
import Buttons from './Buttons.vue'
defineProps<{
artist: Artist;
useCircularImage?: boolean;
}>();
artist: Artist
useCircularImage?: boolean
}>()
</script>
<style lang="scss">
.artist-info {
z-index: 1;
padding: 1rem;
padding-right: 0;
z-index: 1;
padding: 1rem;
padding-right: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 1rem;
.text {
display: flex;
flex-direction: column;
gap: $small;
}
justify-content: flex-end;
.card-title {
font-size: small;
font-weight: 700;
}
gap: 1rem;
.artist-name {
font-size: 3.5rem;
font-weight: 700;
word-wrap: break-all;
margin-left: -1px;
}
.text {
display: flex;
flex-direction: column;
gap: $small;
}
.stats {
font-size: small;
font-weight: 700;
}
.card-title {
font-size: small;
font-weight: 700;
}
.artist-name {
font-size: 3.5rem;
font-weight: 700;
word-wrap: break-all;
margin-left: -1px;
}
.stats {
font-size: small;
font-weight: 700;
}
}
</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

@@ -0,0 +1,136 @@
<template>
<div class="itemsortby">
<div class="tt select circular">Sort By</div>
<div class="left group">
<SortKey
:items="($route.name == Routes.AlbumList ? albumitems : artistitems).concat(items)"
:sortby="store.sortby"
:reverse="store.reverse"
/>
</div>
<div class="right group">
<div class="tt select circular"><ChartSvg /></div>
<SortKey :items="statitems" :sortby="store.sortby" :reverse="store.reverse" />
</div>
</div>
</template>
<script setup lang="ts">
import { Routes } from '@/router'
import { useRoute } from 'vue-router'
import { useAlbumList, useArtistList } from '@/stores/pages/itemlist'
import SortKey from './SortKey.vue'
import ChartSvg from '@/assets/icons/chart.svg'
const route = useRoute()
const store = route.name === Routes.AlbumList ? useAlbumList() : useArtistList()
const items = [
{ key: 'trackcount', displayName: 'No. of tracks' },
{ key: 'duration', displayName: 'Duration' },
{ key: 'created_date', displayName: 'Date added' },
{ key: 'lastplayed', displayName: 'Last played' },
]
const statitems = [
{ key: 'playcount', displayName: 'Plays' },
{ key: 'playduration', displayName: 'Play duration' },
]
const albumitems = [
{ key: 'title', displayName: 'Title' },
{ key: 'albumartists', displayName: 'Artist' },
{ key: 'date', displayName: 'Year released' },
]
const artistitems = [
{ key: 'name', displayName: 'Name' },
{ key: 'albumcount', displayName: 'No. of albums' },
]
</script>
<style lang="scss">
.itemsortby {
z-index: 200;
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: 1rem;
@include allPhones {
grid-template-columns: 1fr;
.tt {
display: none !important;
}
}
.group {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1rem;
}
padding: 1rem $medium 2rem $medium;
position: relative;
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
user-select: none;
.select {
cursor: pointer;
display: flex;
align-items: center;
border: solid 1px $gray5;
padding: $small $medium;
transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
}
.select.circular {
user-select: none;
pointer-events: none;
}
.reverse svg.direction {
transform: rotate(90deg);
}
.select:hover {
background-color: $gray4;
border-color: $gray4;
}
.tt {
background-color: #fff;
color: #000;
border: none;
height: max-content;
display: flex;
gap: $small;
svg {
height: 1rem;
}
}
svg.direction {
transform: rotate(-90deg);
margin: -2px 0;
margin-right: -6px;
margin-left: 2px;
transition: transform 0.1s linear;
}
.active {
background-color: $gray4;
border-color: $gray4;
}
button {
padding-left: $medium;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<RouterLink
v-for="i in items"
:key="i.key"
:to="{
name: $route.name as string,
query: {
sortby: i.key,
reverse: i.key == sortby ? ($route.query.reverse == '0' ? '1' : '0') : '1',
},
replace: true,
}"
class="select rounded-sm"
:class="{ reverse: reverse, active: i.key == sortby }"
>
{{ i.displayName }}
<svg
v-if="i.key == sortby"
width="18"
height="18"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="direction"
>
<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>
</RouterLink>
</template>
<script setup lang="ts">
defineProps<{
sortby: string
items: {
key: string
displayName: string
}[]
reverse: boolean
}>()
</script>
<style scoped></style>

View File

@@ -1,222 +1,291 @@
<template>
<div
ref="parentRef"
class="context-item"
@mouseenter="option.children && !isSmall && childrenShowMode === contextChildrenShowMode.hover && showChildren()"
@mouseleave="option.children && !isSmall && childrenShowMode === contextChildrenShowMode.hover && hideChildren()"
@click="runAction"
>
<div class="icon image" v-html="option.icon"></div>
<div class="label ellip">{{ option.label }}</div>
<div v-if="option.children" class="more" v-html="ExpandIcon"></div>
<div v-if="option.children" ref="childRef" class="children rounded shadow-sm">
<div className="wrapper">
<!-- TODO: -->
<div
ref="parentRef"
class="context-item"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="runAction"
>
<div
v-for="child in option.children"
:key="child.label"
class="context-item"
:class="[{ critical: child.critical }, child.type]"
@click="child.action && runChildAction(child.action)"
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 class="label ellip">
{{ child.label }}
</div>
<div className="wrapper">
<div
v-for="child in children"
:key="child.label"
class="context-item"
:class="[{ critical: child.critical }, child.type]"
@click="child.action && runChildAction(child.action)"
>
<div class="label ellip">
{{ child.label }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createPopper, Instance, Modifier, Placement, Rect } from "@popperjs/core";
import { ref } from "vue";
import {
createPopper,
Instance,
Modifier,
Placement,
Rect,
} from '@popperjs/core'
import { computed, ref } from 'vue'
import { contextChildrenShowMode } from "@/enums";
import { ExpandIcon } from "@/icons";
import { Option } from "@/interfaces";
import { isSmall } from "@/stores/content-width";
import { contextChildrenShowMode } from '@/enums'
import { ExpandIcon } from '@/icons'
import { Option } from '@/interfaces'
const props = defineProps<{
option: Option;
childrenShowMode: contextChildrenShowMode;
}>();
option: Option
childrenShowMode: contextChildrenShowMode
}>()
const emit = defineEmits<{
// eslint-disable-next-line no-unused-vars
(event: "hideContextMenu"): void;
}>();
// eslint-disable-next-line no-unused-vars
(event: 'hideContextMenu'): void
}>()
const parentRef = ref<HTMLElement>();
const childRef = ref<HTMLElement>();
const childrenShown = ref(false);
const showChildrenDelay = 250
const stillWaitingForChildren = ref(false)
const children = ref<Option[] | false>(false)
let popperInstance: Instance | null = null;
const childrenShown = ref(false)
const childRef = ref<HTMLElement>()
const parentRef = ref<HTMLElement>()
function showChildren() {
if (childrenShown.value) {
childrenShown.value = false;
return;
}
const hasChildren = computed(() => {
return (
props.option.children &&
props.childrenShowMode === contextChildrenShowMode.hover
)
})
const offsetModifier: Modifier<
"offset",
{ offset: [number, number] | ((args: { placement: Placement; reference: Rect; popper: Rect }) => [number, number]) }
> = {
name: "offset",
options: {
offset: ({ placement }) => {
// Correct type for placement automatically inferred
if (placement.includes("right") || placement.includes("left")) {
return [-7, 0];
let popperInstance: Instance | null = null
async function handleMouseEnter() {
if (!hasChildren.value) return
stillWaitingForChildren.value = true
await new Promise((resolve) => setTimeout(resolve, showChildrenDelay))
if (stillWaitingForChildren.value) {
showChildren()
}
}
function handleMouseLeave() {
if (!hasChildren.value) return
stillWaitingForChildren.value = false
hideChildren()
}
async function getChildren() {
if (!props.option.children) return
const childs = await props.option.children()
if (childs) {
children.value = childs
}
}
async function showChildren() {
if (childrenShown.value) {
childrenShown.value = false
return
}
if (props.option.children) {
await getChildren()
// return;
}
const offsetModifier: Modifier<
'offset',
{
offset:
| [number, number]
| ((args: {
placement: Placement
reference: Rect
popper: Rect
}) => [number, number])
}
return [0, 0];
},
},
};
> = {
name: 'offset',
options: {
offset: ({ placement }) => {
// Correct type for placement automatically inferred
if (placement.includes('right') || placement.includes('left')) {
return [-7, 0]
}
return [0, 0]
},
},
}
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",
},
},
offsetModifier,
],
});
childRef.value ? (childRef.value.style.visibility = "visible") : null;
childRef.value ? (childRef.value.style.opacity = "1") : null;
childrenShown.value = true;
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',
},
},
offsetModifier,
],
}
)
childRef.value ? (childRef.value.style.visibility = 'visible') : null
childRef.value ? (childRef.value.style.opacity = '1') : null
childrenShown.value = true
}
function hideChildren() {
childRef.value ? (childRef.value.style.visibility = "hidden") : null;
childRef.value ? (childRef.value.style.opacity = "0") : null;
popperInstance?.destroy();
childrenShown.value = false;
childRef.value ? (childRef.value.style.visibility = 'hidden') : null
childRef.value ? (childRef.value.style.opacity = '0') : null
popperInstance?.destroy()
childrenShown.value = false
}
function hideContextMenu() {
if (props.option.children) return;
emit("hideContextMenu");
emit('hideContextMenu')
}
function runAction() {
if (props.option.children) {
if (childrenShown.value) {
hideChildren();
return;
if (!props.option.singleChild && props.option.children) {
if (childrenShown.value) {
hideChildren()
return
}
showChildren()
return
}
showChildren();
return;
}
props.option.action && props.option.action();
hideContextMenu();
props.option.action && props.option.action()
hideContextMenu()
}
function runChildAction(action: () => void) {
action();
emit("hideContextMenu");
action()
emit('hideContextMenu')
}
</script>
<style lang="scss">
.context-item {
width: 100%;
display: flex;
align-items: center;
padding: 0.4rem;
position: relative;
border-radius: $small;
transition: background-color 0.2s ease-out;
width: 100%;
display: flex;
align-items: center;
padding: 0.4rem;
position: relative;
border-radius: $small;
transition: background-color 0.2s ease-out;
.more {
height: 1.5rem;
width: 1.5rem;
position: absolute;
right: 2px;
bottom: 5px;
transform: scale(0.65);
}
.children {
position: absolute;
width: 12rem;
z-index: 10;
transform: scale(0);
background-color: $context;
padding: $small $smaller;
border: solid 1px $gray3;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease-out, visibility 0.25s ease-out;
::-webkit-scrollbar-thumb {
background-color: transparent;
.more {
height: 1.5rem;
width: 1.5rem;
position: absolute;
right: 2px;
bottom: 5px;
transform: scale(0.65);
}
&:hover ::-webkit-scrollbar-thumb {
background-color: $gray2;
.children {
position: absolute;
width: 12rem;
z-index: 10;
transform: scale(0);
background-color: $context;
padding: $small $smaller;
border: solid 1px $gray3;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease-out, visibility 0.25s ease-out;
::-webkit-scrollbar-thumb {
background-color: transparent;
}
&:hover ::-webkit-scrollbar-thumb {
background-color: $gray2;
}
&:hover ::-webkit-scrollbar-thumb:hover {
background-color: $gray1;
}
.wrapper {
padding: 0 $smaller;
overflow: auto;
overflow-x: hidden;
max-height: calc(100vh / 2 - 2rem);
-webkit-overflow-scrolling: touch;
}
.context-item {
line-height: 1.2;
padding: $small 1rem;
padding: 0.4rem 0.6rem;
}
.separator {
padding: 0;
}
}
&:hover ::-webkit-scrollbar-thumb:hover {
background-color: $gray1;
&:hover {
background: $darkestblue;
}
.wrapper {
padding: 0 $smaller;
overflow: auto;
overflow-x: hidden;
max-height: calc(100vh / 2 - 2rem);
-webkit-overflow-scrolling: touch;
.icon {
height: 1.25rem;
width: 1.25rem;
margin-right: $small;
svg {
height: 100%;
width: 100%;
}
}
.context-item {
line-height: 1.2;
padding: $small 1rem;
padding: 0.4rem 0.6rem;
// add to queue icon
&:nth-child(2) .icon > svg {
transform: scale(0.85);
}
.separator {
padding: 0;
.label {
width: 9rem;
}
}
&:hover {
background: $darkestblue;
}
.icon {
height: 1.25rem;
width: 1.25rem;
margin-right: $small;
svg {
height: 100%;
width: 100%;
transform: scale(1.15);
}
}
// add to queue icon
&:nth-child(3) .icon > svg {
transform: scale(0.9);
}
.label {
width: 9rem;
}
}
</style>

View File

@@ -1,94 +1,125 @@
<template>
<div class="breadcrumb-nav">
<div
v-for="path in subPaths"
:key="path.path"
class="path"
:class="{ inthisfolder: path.active }"
@click.prevent="$emit('navigate', path.path)"
>
<a class="text">{{ path.name }}</a>
<!-- 👆 the a tag was misused to avoid rewriting css after moving this code to a component -->
<div class="breadcrumb-nav">
<div
v-for="path in props.subPaths ? props.subPaths : subPaths"
:key="path.path"
class="path"
:class="{ inthisfolder: path.active }"
@click.prevent="$emit('navigate', path.path)"
>
<a class="text">{{ path.name }}</a>
<!-- 👆 the a tag was misused to avoid rewriting css after moving this code to a component -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onUpdated } from "vue";
import { Ref, onMounted, onUpdated, ref, watch } from 'vue'
import { subPath } from "@/interfaces";
import { focusElemByClass } from "@/utils";
import { subPath } from '@/interfaces'
import { createSubPaths, focusElemByClass } from '@/utils'
import useSettings from "@/stores/settings";
import useSettings from '@/stores/settings'
import useFolder from '@/stores/pages/folder'
const settings = useSettings();
const props = defineProps<{
subPaths?: subPath[]
}>()
defineProps<{
subPaths: subPath[];
}>();
const folder = useFolder()
const settings = useSettings()
const subPaths: Ref<subPath[]> = ref([])
let oldpath = ''
const getSubPaths = (newPath: string) => {
;[oldpath, subPaths.value] = createSubPaths(newPath, oldpath)
}
// INFO: if there are no subPaths, watch the folder path
if (!(props.subPaths && props.subPaths.length)) {
watch(
() => folder.path,
newPath => {
newPath = newPath as string
if (newPath == undefined) return
getSubPaths(newPath)
}
)
}
defineEmits<{
(e: "navigate", path: string): void;
}>();
(e: 'navigate', path: string): void
}>()
onUpdated(() => {
if (settings.is_default_layout) {
focusElemByClass("inthisfolder");
}
});
if (settings.is_default_layout) {
focusElemByClass('inthisfolder')
}
})
onMounted(() => {
if (props.subPaths != undefined) {
return
}
getSubPaths(folder.path)
})
</script>
<style lang="scss">
.designatedOS .breadcrumb-nav {
&::-webkit-scrollbar {
display: none;
}
&::-webkit-scrollbar {
display: none;
}
}
.breadcrumb-nav {
display: flex;
gap: $smaller;
.path {
white-space: nowrap;
margin: auto 0;
cursor: pointer;
display: flex;
align-items: center;
gap: $smaller;
.text {
font-size: 1rem;
font-weight: 500;
padding: $smaller $small;
border-radius: $smaller;
transition: background-color 0.2s ease-out;
.path {
white-space: nowrap;
margin: auto 0;
cursor: pointer;
display: flex;
align-items: center;
.text {
font-size: 1rem;
font-weight: 500;
padding: $smaller $small;
border-radius: $smaller;
transition: background-color 0.2s ease-out;
}
&::before {
content: '';
margin-right: $smaller;
color: $gray2;
font-size: 1rem;
}
// &:first-child {
// display: none;
// }
&:last-child {
padding-right: $smaller;
}
&:hover {
.text {
background-color: $gray;
}
}
}
&::before {
content: "";
margin-right: $smaller;
color: $gray2;
font-size: 1rem;
}
// &:first-child {
// display: none;
// }
&:last-child {
padding-right: $smaller;
}
&:hover {
.text {
.inthisfolder > .text {
background-color: $gray;
}
transition: all 0.5s;
}
}
.inthisfolder > .text {
background-color: $gray;
transition: all 0.5s;
}
}
</style>

View File

@@ -76,7 +76,7 @@ function showContextMenu(e: MouseEvent) {
<style lang="scss">
.f-item {
height: 5rem;
height: 4rem;
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;

View File

@@ -5,19 +5,35 @@
<RouterLink
v-for="i in browselist"
:key="i.title"
class="browseitem rounded-sm t-center"
:to="{ name: i.route, params: i.params }"
class="browseitem rounded-sm"
:to="{ name: i.route || '', params: i.params }"
:style="{ width: `${album_card_with - 24}px` }"
@click="i.action && i.action()"
:class="i.class"
>
{{ i.title }}
<div class="icon" v-html="i.icon"></div>
<div style="width: 100%">
{{ i.title }}
</div>
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import {
AlbumIcon,
ArtistIcon,
FolderIcon,
HeartIcon,
PlaylistIcon,
SettingsIcon,
ReloadIcon
} from "@/icons";
import { triggerScan } from "@/requests/settings/rootdirs";
import { Routes } from "@/router";
import { album_card_with } from "@/stores/content-width";
import useDialog from "@/stores/modal";
const browselist = [
{
@@ -26,31 +42,68 @@ const browselist = [
params: {
path: "$home",
},
icon: FolderIcon,
},
{
title: "Albums",
route: Routes.AlbumList,
icon: AlbumIcon,
},
{
title: "Artists",
route: Routes.ArtistList,
icon: ArtistIcon,
},
{
title: "Playlists",
route: Routes.playlists,
icon: PlaylistIcon,
},
{
title: "Favorites",
route: Routes.favorites,
icon: HeartIcon,
class: "favorite",
},
{
title: "Favorite Tracks",
title: "Fav. tracks",
route: Routes.favoriteTracks,
icon: HeartIcon,
class: "favorite",
},
{
title: "Favorite Artists",
title: "Fav. artists",
route: Routes.favoriteArtists,
icon: ArtistIcon,
class: "favorite",
},
{
title: "Fav. albums",
route: Routes.favoriteAlbums,
icon: AlbumIcon,
class: "favorite",
},
{
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>
@@ -74,10 +127,33 @@ const browselist = [
.browseitem {
font-weight: 500;
padding: 1.5rem 0;
padding: 1.25rem 1rem;
background-color: $gray;
color: $white;
transition: background-color 0.2s ease-out;
display: grid;
grid-template-columns: max-content 1fr;
place-items: center;
gap: $small;
.icon {
height: 1.75rem;
}
svg {
height: 1.75rem;
color: $gray1;
}
}
.settings svg {
color: $brown;
}
.reload svg {
// INFO: The icons is a bit larger than the others
width: 1.25rem;
}
.browseitem:hover {

View File

@@ -34,6 +34,4 @@
<script setup lang="ts">
import { menus } from "./navitems";
</script>
<style scoped></style>
</script>

View File

@@ -1,86 +1,89 @@
<template>
<div class="hotkeys no-scroll">
<button @click.prevent="queue.playPrev">
<PrevSvg />
</button>
<button @click.prevent="queue.playPause">
<Spinner v-if="buffering && queue.playing" />
<PauseSvg v-else-if="queue.playing" />
<PlaySvg v-else />
</button>
<button @click.prevent="queue.playNext">
<NextSvg />
</button>
</div>
<div class="hotkeys no-scroll">
<button @click.prevent="queue.playPrev">
<PrevSvg />
</button>
<button @click.prevent="queue.playPause">
<Spinner v-if="buffering && queue.playing" />
<PauseSvg v-else-if="queue.playing" />
<PlaySvg class="playsvg" v-else />
</button>
<button @click.prevent="queue.playNext">
<NextSvg />
</button>
</div>
</template>
<script setup lang="ts">
import { usePlayer } from "@/stores/player";
import useQStore from "@/stores/queue";
import { buffering } from '@/stores/player'
import useQStore from '@/stores/queue'
import { default as NextSvg, default as PrevSvg } from "@/assets/icons/next.svg";
import PauseSvg from "@/assets/icons/pause.svg";
import PlaySvg from "@/assets/icons/play.svg";
import Spinner from "@/components/shared/Spinner.vue";
import { default as NextSvg, default as PrevSvg } from '@/assets/icons/next.svg'
import PauseSvg from '@/assets/icons/pause.svg'
import PlaySvg from '@/assets/icons/play.svg'
import Spinner from '@/components/shared/Spinner.vue'
const queue = useQStore();
const { buffering } = usePlayer();
const queue = useQStore()
</script>
<style lang="scss">
.hotkeys {
display: grid;
grid-template-columns: 1fr 4rem 1fr;
gap: 1rem;
height: 100%;
align-items: center;
button {
display: grid;
grid-template-columns: 1fr 4rem 1fr;
gap: 1rem;
height: 100%;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 0;
align-items: center;
&:hover {
background: $darkestblue;
button {
height: 100%;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 0;
&:hover {
background: $darkestblue;
}
}
}
button:first-child {
svg {
transform: rotate(180deg);
}
&:active {
svg {
transform: rotate(180deg) scale(0.75);
}
}
}
button:nth-child(2) {
width: 4rem;
}
@include allPhones {
grid-template-columns: 1fr max-content 1fr;
position: relative;
margin-right: -$small;
gap: 0;
button:first-child {
margin-left: $small;
}
}
svg {
transform: rotate(180deg);
}
@include largePhones {
display: flex;
flex-shrink: 0;
button:first-child {
margin-left: $smaller;
&:active {
svg {
transform: rotate(180deg) scale(0.75);
}
}
}
button:nth-child(2) {
width: 4rem;
}
@include allPhones {
grid-template-columns: 1fr max-content 1fr;
position: relative;
margin-right: -$small;
gap: 0;
button:first-child {
margin-left: $small;
}
}
@include largePhones {
display: flex;
flex-shrink: 0;
button:first-child {
margin-left: $smaller;
}
}
.playsvg {
height: 1.75rem;
}
}
}
</style>

View File

@@ -1,35 +1,42 @@
<template>
<input
id="progress"
type="range"
:value="time.current"
min="0"
:max="time.full"
step="0.1"
:style="{ backgroundSize: `${(time.current / (time.full || 1)) * 100}% 100%` }"
@change="seek"
/>
<input
id="progress"
type="range"
:value="time.current"
min="0"
:max="time.full"
step="0.1"
:style="{
background: `#3a3a3c linear-gradient(90deg, white ${currentPercent}%, #48484a ${currentPercent}%, #48484a ${maxSeekPercent}%, #3a3a3c ${maxSeekPercent}%)`,
}"
@change="seek"
@click="seek"
/>
</template>
<script setup lang="ts">
import useQStore from "@/stores/queue";
import { maxSeekPercent } from '@/stores/player'
import useQStore from '@/stores/queue'
import { computed } from 'vue'
const q = useQStore();
const q = useQStore()
const { duration: time } = q;
const { duration: time } = q
let prevHash = "";
let prevHash = ''
const seek = (e: Event) => {
if (prevHash && prevHash !== q.currenttrackhash) {
prevHash = "";
return;
}
if (prevHash && prevHash !== q.currenttrackhash) {
prevHash = ''
return
}
const elem = e.target as HTMLInputElement;
const value = elem.value;
const elem = e.target as HTMLInputElement
const value = elem.value
prevHash = q.currenttrackhash;
q.seek(value as unknown as number);
};
prevHash = q.currenttrackhash
q.seek(value as unknown as number)
}
const currentPercent = computed(() => (time.current / (time.full || 1)) * 100)
</script>

View File

@@ -38,7 +38,7 @@ import useQueueStore from "@/stores/queue";
import Bitrate from "./Bitrate.vue";
const imguri = paths.images.thumb.large;
const imguri = paths.images.thumb.medium;
const q = useQueueStore();
</script>

View File

@@ -5,7 +5,7 @@
:key="index"
v-wave
:to="{
name: menu.route_name,
name: menu.route_name || '',
params: menu?.params,
query: menu.query && menu.query(),
}"
@@ -14,6 +14,7 @@
separator: menu.separator,
active: $route.name === menu.route_name,
}"
@click="menu.action && menu.action()"
>
<div v-if="!menu.separator">
<component :is="menu.icon" />

View File

@@ -1,5 +1,6 @@
import { Routes } from "@/router";
import useSearchStore from "@/stores/search";
import useDialog from "@/stores/modal";
import useSearch from "@/stores/search";
import FolderSvg from "@/assets/icons/folder-1.svg";
import HeartSvg from "@/assets/icons/heart.svg";
@@ -40,7 +41,7 @@ export const menus = [
name: "search",
route_name: Routes.search,
params: { page: "top" },
query: () => ({ q: useSearchStore().query }),
query: () => ({ q: useSearch().query }),
icon: SearchSvg,
},
{
@@ -53,9 +54,11 @@ export const menus = [
},
{
name: "settings",
route_name: Routes.settings,
params: { tab: "general" },
route_name: null,
icon: SettingsSvg,
action: () => {
useDialog().showSettingsModal()
}
},
];

View File

@@ -20,7 +20,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRoute } from "vue-router";
import ArtistName from "../shared/ArtistName.vue";
import HeartSvg from "../shared/HeartSvg.vue";
@@ -29,7 +28,6 @@ import OptionSvg from "@/assets/icons/more.svg";
import { showTrackContextMenu } from "@/helpers/contextMenuHandler";
import useQueueStore from "@/stores/queue";
const route = useRoute();
const context_menu_showing = ref(false);
const queue = useQueueStore();
@@ -41,7 +39,7 @@ defineEmits<{
function showMenu(e: MouseEvent) {
if (!queue.currenttrack) return;
showTrackContextMenu(e, queue.currenttrack, context_menu_showing, route, false);
showTrackContextMenu(e, queue.currenttrack, context_menu_showing);
}
</script>

View File

@@ -5,11 +5,20 @@
{
background: bg,
backgroundPosition: `center ${info.settings.banner_pos}%`,
height: `${heightLarge || isSmallPhone ? '24rem' : '18rem'}`,
height: `${isSmallPhone ? '24rem' : '18rem'}`,
},
]"
:class="{ 'use-sqr_img': useSqrImg }"
>
<div
v-if="!info.has_image || info.settings.square_img"
class="album-header-ambient rounded-lg"
style="height: 100%; width: 100%"
:style="{
// hide shadow on small screen
boxShadow: isSmallPhone ? '' : colors.bg ? `0 .5rem 2rem ${colors.bg}` : '0 .5rem 2rem black',
}"
></div>
<div
v-if="Number.isInteger(info.id)"
class="float"
@@ -22,15 +31,7 @@
<PinSvg v-else />
</div>
<div v-if="info.has_image" class="gradient rounded-lg"></div>
<div
v-if="!info.has_image || info.settings.square_img"
class="album-header-ambient rounded-lg"
style="height: 100%; width: 100%"
:style="{
boxShadow: colors.bg ? `0 .5rem 2rem ${colors.bg}` : '0 .5rem 2rem black',
}"
></div>
<div v-if="!isSmallPhone && info.has_image" class="gradient rounded-lg"></div>
<div v-if="info.has_image && useSqrImg" class="sqr_img">
<img :src="(playlist.info.image as string)" class="rounded-sm" />
</div>
@@ -45,7 +46,7 @@ import { storeToRefs } from "pinia";
import { computed } from "vue";
import { pinUnpinPlaylist } from "@/requests/playlists";
import { heightLarge, isSmallPhone } from "@/stores/content-width";
import { isSmallPhone } from "@/stores/content-width";
import usePStore from "@/stores/pages/playlist";
import { getTextColor } from "@/utils/colortools/shift";
@@ -60,6 +61,11 @@ const playlist = usePStore();
const { info, colors } = storeToRefs(playlist);
const bg = computed(() => {
// hide background on small screen
if (isSmallPhone.value){
return "";
}
if (playlist.info.has_image) {
if (isSmallPhone.value || (!playlist.info.settings.square_img && !isSmallPhone.value)) {
return `url(${info.value.image})`;
@@ -127,14 +133,20 @@ function pinPlaylist(pid: number) {
align-items: flex-start;
gap: 1rem;
// take up the space left by the gradient element
height: 25rem !important;
.playlist-info {
text-align: center;
height: max-content;
}
.sqr_img {
height: 13rem;
width: 13rem;
height: 12rem;
width: 12rem;
margin-top: 1rem;
margin: 0 auto;
}
.title {

View File

@@ -1,43 +1,43 @@
<template>
<div
class="playlist-banner-images no-scroll"
:style="{
background: (playlist.info.images[1] as any).color,
<div
class="playlist-banner-images no-scroll"
:style="{
background: playlist.info.images.length ? (playlist.info.images[1] as any).color : undefined,
}"
>
<img
v-for="(img, index) in playlist.info.images"
:key="index"
:src="paths.images.thumb.large + (img as any).image"
class=""
/>
</div>
>
<img
v-for="(img, index) in playlist.info.images"
:key="index"
:src="paths.images.thumb.medium + (img as any).image"
class=""
/>
</div>
</template>
<script setup lang="ts">
import { paths } from "@/config";
import usePStore from "@/stores/pages/playlist";
import { paths } from '@/config'
import usePStore from '@/stores/pages/playlist'
const playlist = usePStore();
const playlist = usePStore()
</script>
<style lang="scss">
.playlist-banner-images {
display: grid;
grid: repeat(2, 1fr) / repeat(2, 1fr);
transition: all 0.2s ease-in-out;
img {
height: 7rem;
display: grid;
grid: repeat(2, 1fr) / repeat(2, 1fr);
transition: all 0.2s ease-in-out;
}
@include largePhones {
right: -4rem;
img {
height: 7rem;
height: 7rem;
transition: all 0.2s ease-in-out;
}
@include largePhones {
right: -4rem;
img {
height: 7rem;
}
}
}
}
</style>

View File

@@ -14,7 +14,7 @@
{{ formatSeconds(playlist.info.duration, true) }}
</div>
<div ref="test_elem"></div>
<div class="title" :class="`${playlist.info.settings.square_img && isSmall ? 'ellip' : 'ellip2'}`">
<div class="title ellip2">
<span v-for="t in balanceText(playlist.info.name, test_elem?.offsetWidth || 0, '4rem')" :key="t">
{{ t }}
<br />
@@ -25,7 +25,6 @@
</template>
<script setup lang="ts">
import { playSources } from "@/enums";
import { isSmall } from "@/stores/content-width";
import { formatSeconds } from "@/utils";
import PlayBtnRect from "@/components/shared/PlayBtnRect.vue";

View File

@@ -3,12 +3,11 @@
<span
v-if="!isHeaderSmall"
class="status"
>Last updated {{ playlist.info.last_updated }}</span
>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) ? ' | ' : '' }}

View File

@@ -1,7 +1,7 @@
<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.large + img" />
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + img['image']" />
</div>
<img v-else :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<div class="overlay rounded">

View File

@@ -1,75 +1,105 @@
<template>
<div class="r-sidebar">
<SearchInput />
<div v-auto-animate class="r-content no-scroll">
<div v-if="tabs.current === tabs.tabs.home" class="r-dash">
<DashBoard />
</div>
<div v-if="tabs.current === tabs.tabs.search" class="r-search">
<Search />
</div>
<div v-if="tabs.current === tabs.tabs.queue" class="r-queue">
<Queue />
</div>
<div class="r-sidebar">
<div class="rtopbar">
<SearchInput />
<AvatarWithDropdown />
</div>
<div v-auto-animate class="r-content no-scroll">
<div v-if="tabs.current === tabs.tabs.home" class="r-dash">
<DashBoard />
</div>
<div v-if="tabs.current === tabs.tabs.search" class="r-search">
<Search />
</div>
<div v-if="tabs.current === tabs.tabs.queue" class="r-queue">
<Queue />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useTabStore from "@/stores/tabs";
import useTabStore from '@/stores/tabs'
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'
import DashBoard from './Home/Main.vue'
import Queue from './Queue.vue'
import Search from './Search/Main.vue'
import SearchInput from './SearchInput.vue'
const tabs = useTabStore();
const tabs = useTabStore()
</script>
<style lang="scss">
.r-sidebar {
width: 100%;
display: grid;
grid-template-rows: max-content 1fr;
background-color: rgb(22, 22, 22);
padding-bottom: 1rem;
border-top: none;
border-bottom: none;
margin-bottom: -1rem;
.gsearch-input {
height: 2.5rem;
margin: 1rem;
}
.r-content {
width: 100%;
height: 100%;
background-color: $gray;
display: grid;
grid-template-rows: max-content 1fr;
background-color: rgb(22, 22, 22);
padding-bottom: 1rem;
border-top: none;
border-bottom: none;
margin-bottom: -1rem;
.r-search {
height: 100%;
.rtopbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 1rem;
}
.r-dash {
height: 100%;
.gsearch-input {
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-queue {
height: 100%;
overflow: hidden;
display: grid;
gap: $small;
grid-template-rows: max-content 1fr;
.r-content {
width: 100%;
height: 100%;
background-color: $gray;
.r-search {
height: 100%;
}
.r-dash {
height: 100%;
}
.r-queue {
height: 100%;
overflow: hidden;
display: grid;
gap: $small;
grid-template-rows: max-content 1fr;
}
}
}
}
.designatedOS .r-sidebar > .r-content > .r-queue > .queue-virtual-scroller > .scroller::-webkit-scrollbar-track {
background-color: $gray;
background-color: $gray;
}
.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,75 @@
<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));
}
.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;
}
#right-tabs.tabContent {
grid-template-rows: min-content 1fr;
grid-template-rows: min-content 1fr;
}
</style>

View File

@@ -1,201 +1,206 @@
<template>
<RouterLink
:to="{
name: res_type === 'artist' ? Routes.artist : Routes.album,
params: res_type === 'artist' ? { hash: item.artisthash || ' ' } : { albumhash: item.albumhash || ' ' },
}"
class="top-result-item rounded"
>
<img
:src="res_type === 'artist' ? paths.images.artist.medium + item.image : paths.images.thumb.medium + item.image"
alt=""
class="rounded-sm"
:class="{ circular: res_type === 'artist' }"
/>
<div class="info" :class="{ 'is-artist': res_type === 'artist' }">
<div class="type pad-sm rounded">{{ res_type }}</div>
<div>
<h3>
{{ res_type === "artist" ? item.name : item.title }}
</h3>
<div v-if="res_type === 'album'" class="artists flex">
<span> {{ item.date }}</span> &nbsp; &nbsp;
<ArtistName :artists="item.albumartists" :albumartists="''" />
<RouterLink
:to="{
name: res_type === 'artist' ? Routes.artist : Routes.album,
params: res_type === 'artist' ? { hash: item.artisthash || ' ' } : { albumhash: item.albumhash || ' ' },
}"
class="top-result-item rounded"
>
<img
:src="
res_type === 'artist' ? paths.images.artist.medium + item.image : paths.images.thumb.medium + item.image
"
alt=""
class="rounded-sm"
:class="{ circular: res_type === 'artist' }"
/>
<div class="info" :class="{ 'is-artist': res_type === 'artist' }">
<div class="type pad-sm rounded">{{ res_type }}</div>
<div>
<h3>
{{ res_type === 'artist' ? item.name : item.title }}
</h3>
<div v-if="res_type === 'album'" class="artists flex">
<span> {{ formatDate(item.date, true) }}</span> &nbsp; &nbsp;
<ArtistName :artists="item.albumartists" :albumartists="''" />
</div>
<div v-if="res_type === 'artist'" class="artists flex">
{{ item.albumcount }}
{{ item.albumcount === 1 ? 'album' : 'albums' }}
{{ item.trackcount }}
{{ item.trackcount === 1 ? 'track' : 'tracks' }}
</div>
<div v-if="res_type === 'track'" class="artists flex">
<ArtistName :artists="item.artists" :albumartists="item.albumartists" />
&nbsp; &nbsp;
{{ formatSeconds(item.duration, true) }}
</div>
</div>
</div>
<div v-if="res_type === 'artist'" class="artists flex">
{{ item.albumcount }}
{{ item.albumcount === 1 ? "album" : "albums" }}
{{ item.trackcount }}
{{ item.trackcount === 1 ? "track" : "tracks" }}
<div class="buttons">
<span v-if="res_type !== 'track'"></span>
<button
v-if="res_type === 'track'"
:class="{ context_menu_showing }"
class="context-menu-button"
@click.prevent="showMenu"
>
<Moresvg />
</button>
<PlayBtn
:source="
res_type == 'album'
? playSources.album
: res_type == 'artist'
? playSources.artist
: playSources.track
"
:album-hash="item.albumhash"
:album-name="item.title"
:artisthash="item.artisthash"
:artistname="item.name"
:track="item"
/>
</div>
<div v-if="res_type === 'track'" class="artists flex">
<ArtistName :artists="item.artists" :albumartists="item.albumartists" />
&nbsp; &nbsp;
{{ formatSeconds(item.duration, true) }}
</div>
</div>
</div>
<div class="buttons">
<span v-if="res_type !== 'track'"></span>
<button
v-if="res_type === 'track'"
:class="{ context_menu_showing }"
class="context-menu-button"
@click.prevent="showMenu"
>
<Moresvg />
</button>
<PlayBtn
:source="
res_type == 'album' ? playSources.album : res_type == 'artist' ? playSources.artist : playSources.track
"
:album-hash="item.albumhash"
:album-name="item.title"
:artisthash="item.artisthash"
:artistname="item.name"
:track="item"
/>
</div>
</RouterLink>
</RouterLink>
</template>
<script setup lang="ts">
import { Routes } from "@/router";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { Routes } from '@/router'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { showTrackContextMenu as showContext } from "@/helpers/contextMenuHandler";
import useSearchStore from "@/stores/search";
import { showTrackContextMenu as showContext } from '@/helpers/contextMenuHandler'
import useSearchStore from '@/stores/search'
import Moresvg from "@/assets/icons/more.svg";
import ArtistName from "@/components/shared/ArtistName.vue";
import { paths } from "@/config";
import { Album, Artist, Track } from "@/interfaces";
import { formatSeconds } from "@/utils";
import Moresvg from '@/assets/icons/more.svg'
import ArtistName from '@/components/shared/ArtistName.vue'
import { paths } from '@/config'
import { Album, Artist, Track } from '@/interfaces'
import { formatSeconds } from '@/utils'
import PlayBtn from "@/components/shared/PlayBtn.vue";
import { playSources } from "@/enums";
import PlayBtn from '@/components/shared/PlayBtn.vue'
import { playSources } from '@/enums'
import { formatDate } from '@/utils/dates'
const search = useSearchStore();
const route = useRoute();
const search = useSearchStore()
const { top_results } = storeToRefs(search);
const { top_results } = storeToRefs(search)
const res_type = computed(() => {
return top_results.value.top_result.type;
});
return top_results.value.top_result.type
})
type It = Album & Artist & Track;
type It = Album & Artist & Track
const item = computed(() => {
return top_results.value.top_result.item as It;
});
return top_results.value.top_result.item as It
})
const context_menu_showing = ref(false);
const context_menu_showing = ref(false)
function showMenu(e: MouseEvent) {
showContext(e, item.value as Track, context_menu_showing, route);
showContext(e, item.value as Track, context_menu_showing)
}
</script>
<style lang="scss">
.top-result-item {
background-color: $gray5;
padding: 1rem;
display: grid;
gap: 1rem;
align-items: flex-end;
margin: 1rem;
margin-bottom: 2rem;
position: relative;
min-width: 22rem;
max-width: 27rem;
background-color: $gray5;
padding: 1rem;
display: grid;
gap: 1rem;
align-items: flex-end;
margin: 1rem;
margin-bottom: 2rem;
position: relative;
min-width: 22rem;
max-width: 27rem;
.buttons {
position: absolute;
right: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem $medium;
.buttons {
position: absolute;
right: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem $medium;
.play-btn {
width: 2.5rem;
height: 2.5rem;
opacity: 0;
transition: opacity 0.2s ease-in-out, background-color 0.2s ease-out;
.play-btn {
width: 2.5rem;
height: 2.5rem;
opacity: 0;
transition: opacity 0.2s ease-in-out, background-color 0.2s ease-out;
}
}
}
&:hover {
.play-btn {
opacity: 1;
&:hover {
.play-btn {
opacity: 1;
}
}
}
.context-menu-button {
transform: rotate(90deg);
background-color: transparent;
.context-menu-button {
transform: rotate(90deg);
background-color: transparent;
svg {
transform: scale(1.2);
svg {
transform: scale(1.2);
}
}
}
.context_menu_showing {
background-color: $darkestblue;
}
.context_menu_showing {
background-color: $darkestblue;
}
img {
width: 7.5rem;
height: 7.5rem;
object-fit: cover;
}
img {
width: 7.5rem;
height: 7.5rem;
object-fit: cover;
}
.type {
font-size: 0.8rem;
font-weight: 500;
color: white;
background-color: $darkblue;
width: max-content;
padding: 2px $small;
text-transform: capitalize;
}
.type {
font-size: 0.8rem;
font-weight: 500;
color: white;
background-color: $darkblue;
width: max-content;
padding: 2px $small;
text-transform: capitalize;
}
.info {
display: flex;
flex-direction: column;
gap: 0;
.info {
display: flex;
flex-direction: column;
gap: 0;
.is-artist {
text-transform: capitalize;
}
.artists {
font-size: 14px;
font-weight: 500;
opacity: 0.8;
}
h3 {
margin-bottom: $small;
margin-top: 1rem;
font-size: 1.5rem;
}
}
.is-artist {
text-transform: capitalize;
}
.artists {
text-transform: capitalize;
margin-bottom: 1rem;
}
.artists {
font-size: 14px;
font-weight: 500;
opacity: 0.8;
}
h3 {
margin-top: 0;
}
h3 {
margin-bottom: $small;
margin-top: 1rem;
font-size: 1.5rem;
flex-direction: column-reverse;
}
}
.is-artist {
.artists {
text-transform: capitalize;
margin-bottom: 1rem;
}
h3 {
margin-top: 0;
}
flex-direction: column-reverse;
}
}
</style>

View File

@@ -1,210 +1,236 @@
<template>
<div
class="gsearch-input"
@click="
!settings.use_sidebar &&
$route.name !== Routes.search &&
$router.push({
name: Routes.search,
params: { page: 'top' },
query: { q: search.query },
})
"
>
<div id="ginner" ref="inputRef" tabindex="0">
<button
v-auto-animate
:title="tabs.current === tabs.tabs.search ? 'back to queue' : 'go to search'"
:class="{ no_bg: on_nav }"
@click.prevent="handleButton"
>
<SearchSvg v-if="on_nav || tabs.current === tabs.tabs.queue" />
<BackSvg v-else />
</button>
<input
id="globalsearch"
v-model.trim="search.query"
placeholder="Start typing to search"
type="search"
autocomplete="off"
spellcheck="false"
@blur.prevent="removeFocusedClass"
@focus.prevent="addFocusedClass"
/>
<div class="clear_input noSelect" :class="{ active: search.query.length > 0 }" @click="clearInput">X</div>
<div
class="gsearch-input"
@click="
!settings.use_sidebar &&
$route.name !== Routes.search &&
$router.push({
name: Routes.search,
params: { page: 'top' },
query: { q: search.query },
})
"
>
<div id="ginner" ref="inputRef" tabindex="0">
<button
v-auto-animate
:title="tabs.current === tabs.tabs.search ? 'back to queue' : 'go to search'"
:class="{ no_bg: on_nav }"
@click.prevent="handleButton"
>
<SearchSvg v-if="on_nav || tabs.current === tabs.tabs.queue" />
<BackSvg v-else />
</button>
<input
id="globalsearch"
v-model.trim="search.query"
placeholder="Start typing to search"
type="search"
autocomplete="off"
spellcheck="false"
@blur.prevent="removeFocusedClass"
@focus.prevent="addFocusedClass"
/>
<div
class="clear_input circular noSelect"
:class="{ active: search.query.length > 0 }"
@click.stop="clearInput"
>
<CancelSvg />
</div>
</div>
</div>
</div>
</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 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");
}
if (inputRef.value) {
inputRef.value.classList.add('search-focused')
}
}
function removeFocusedClass() {
if (inputRef.value) {
inputRef.value.classList.remove("search-focused");
}
if (inputRef.value) {
inputRef.value.classList.remove('search-focused')
}
}
function clearInput() {
search.query = "";
if (inputRef.value) {
inputRef.value.focus();
}
search.query = ''
if (inputRef.value) {
inputRef.value.focus()
}
}
// @end
function handleButton() {
if (props.on_nav) return;
if (props.on_nav) return
if (tabs.current === tabs.tabs.search) {
tabs.switchToQueue();
} else {
tabs.switchToSearch();
}
if (tabs.current === tabs.tabs.search) {
tabs.switchToQueue()
} else {
tabs.switchToSearch()
}
}
</script>
<style>
.clear_search {
/* Style applied when clear_search class is active */
visibility: visible;
cursor: pointer;
/* Style applied when clear_search class is active */
visibility: visible;
cursor: pointer;
}
</style>
<style lang="scss">
.right > .gsearch-input > #ginner > input {
width: 140px;
width: 150px;
@include allPhones {
width: 100%;
}
@include allPhones {
width: 100%;
}
}
.gsearch-input {
display: grid;
grid-template-columns: 1fr max-content;
display: grid;
grid-template-columns: 1fr max-content;
#ginner {
width: 100%;
display: flex;
align-items: center;
// gap: $small;
border-radius: 3rem;
background-color: $gray5;
outline: solid 2px transparent;
transition: outline-color 0.2s ease-out;
#ginner {
width: 100%;
display: flex;
align-items: center;
border-radius: 3rem;
background-color: $gray5;
outline: solid 2px transparent;
transition: outline-color 0.2s ease-out;
button {
background: transparent;
border: none;
width: 2rem;
height: 2rem;
padding: 0;
margin-left: 4px;
border-radius: 3rem;
cursor: pointer;
flex-shrink: 0;
button {
background: transparent;
border: none;
width: 1.625rem;
height: 1.625rem;
padding: 0;
margin-left: 6px;
margin-right: $smaller;
border-radius: 3rem;
cursor: pointer;
flex-shrink: 0;
&:hover {
transition: all 0.2s ease;
background-color: $gray2;
}
&:hover {
transition: all 0.2s ease;
background-color: $gray2;
}
@include allPhones {
display: none;
}
}
@include allPhones {
display: none;
}
}
button.no_bg {
pointer-events: none;
}
button.no_bg {
pointer-events: none;
}
input {
width: 100%;
border: none;
line-height: 2.25rem;
color: inherit;
font-size: 14px;
font-weight: 500;
background-color: transparent;
outline: none;
appearance: none;
text-overflow: ellipsis;
input {
width: 100%;
border: none;
line-height: 2.25rem;
color: inherit;
font-size: 14px;
font-weight: 500;
background-color: transparent;
outline: none;
appearance: none;
text-overflow: ellipsis;
@include allPhones {
font-size: 0.9rem;
font-weight: 600;
padding-right: $small;
}
}
@include allPhones {
font-size: 0.9rem;
font-weight: 600;
padding-right: $small;
}
.clear_input {
cursor: pointer;
padding: 10px 1rem;
border-top-right-radius: 3rem;
border-bottom-right-radius: 3rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease-out, visibility 0.3s ease-out;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
@include allPhones {
border-radius: unset;
}
}
.clear_input {
cursor: pointer;
margin-right: $smaller;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease-out, visibility 0.3s ease-out, background-color 0.2s ease-out;
width: 1.75rem;
aspect-ratio: 1;
.clear_input.active {
opacity: 1;
visibility: visible;
}
display: grid;
place-items: center;
flex-shrink: 0;
.clear_input.active:active {
opacity: 0.3;
&:hover {
background-color: $gray;
}
svg {
height: 1rem;
}
@include allPhones {
width: $larger;
border-radius: 4px;
margin-right: $small;
}
}
.clear_input.active {
opacity: 1;
visibility: visible;
}
.clear_input.active:active {
opacity: 0.3;
}
@include allPhones {
border-radius: unset;
background-color: transparent;
}
}
@include allPhones {
border-radius: unset;
background-color: transparent;
width: 100%;
}
}
@include allPhones {
width: 100%;
}
}
.search-focused {
outline: solid 2px #fff;
outline: solid 2px #fff;
@include allPhones {
outline: none;
}
@include allPhones {
outline: none;
}
}
</style>

View File

@@ -73,6 +73,7 @@ const settings = useSettings()
}
.links .flex {
flex-wrap: wrap;
margin-top: $small;
gap: 1rem;
}

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

@@ -1,30 +1,18 @@
<template>
<div class="list-items">
<div
v-for="i in items"
:key="i.title"
class="option-list-item"
>
<div v-for="i in items" :key="i.title" class="option-list-item">
<div class="with-icon">
<component :is="icon_" />
<div class="text ellip">
{{ i.title }}
</div>
</div>
<div
class="icon"
@click="i.action"
>
<div class="icon" @click="i.action">
<DeleteSvg />
</div>
</div>
<div
v-if="!items.length"
class="option-list-item"
style="opacity: 0.5"
>
Root directories not configured. Use the "Configure" button above to
configure
<div v-if="!items.length" class="option-list-item" style="opacity: 0.5">
Root directories not configured. Use the "Configure" button above to configure
</div>
</div>
</template>
@@ -56,6 +44,9 @@ const icon_ = getIcon()
<style lang="scss">
.setting-item.is-list {
display: block !important;
// border: solid 1px;
.list-items {
border: solid 1px $gray5;
border-radius: $small;
@@ -72,17 +63,23 @@ const icon_ = getIcon()
gap: 1rem;
svg {
flex-shrink: 0;
width: 1.25rem;
display: block;
}
.with-icon {
display: flex;
gap: $small;
align-items: center;
font-family: "SF Mono", monospace;
font-weight: 500;
font-size: 0.9rem;
}
.with-icon {
display: flex;
gap: $small;
align-items: center;
font-family: 'SF Mono', monospace;
font-weight: 500;
font-size: 0.9rem;
@include smallPhones {
font-size: $medium;
}
}
.icon {
cursor: pointer;

View File

@@ -0,0 +1,84 @@
<template>
<div class="freenuminput rounded-sm">
<div class="spinner" v-if="loading"></div>
<input type="number" :value="props.value" @change="handleInput" />
</div>
</template>
<script setup lang="ts">
import { useToast } from '@/stores/notification'
import { ref } from 'vue'
const toast = useToast()
const props = defineProps<{
value: number
callback: (newValue: number) => Promise<boolean>
}>()
const loading = ref(false)
function handleInput(e: Event) {
const newValue = (e.target as HTMLInputElement).valueAsNumber
if (Number.isNaN(newValue)) {
return toast.showError('Invalid number')
}
if (newValue) {
submit(newValue)
}
}
async function submit(newValue: number) {
const success = await props.callback(newValue)
if (success) {
toast.showSuccess('Updated!')
} else {
toast.showError('Failed to update')
}
}
</script>
<style lang="scss">
.freenuminput {
height: 2rem;
border: solid 1px $gray4;
position: relative;
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-size: 0.875rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
width: 4rem !important;
border: none;
outline: none;
background-color: transparent;
text-align: center;
height: 100%;
}
.spinner {
position: absolute;
left: -2rem;
top: 6px;
border-color: $gray5;
border-top-color: #fff;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
}
</style>

View File

@@ -1,15 +1,15 @@
<template>
<div class="setting-select rounded-sm no-scroll">
<div
v-for="option in optionsWithActive"
:key="option.title"
class="option"
:class="{ active: option.active }"
@click="setterFn(option.value)"
>
{{ option.title }}
<div class="setting-select rounded-sm no-scroll">
<div
v-for="option in optionsWithActive"
:key="option.title"
class="option"
:class="{ active: option.active }"
@click="setterFn(option.value)"
>
{{ option.title }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -17,39 +17,38 @@ import { SettingOption } from "@/interfaces/settings";
import { computed } from "vue";
const props = defineProps<{
options: SettingOption[] | undefined;
source: () => string;
setterFn: (value: any) => void;
options: SettingOption[] | undefined;
source: () => string;
setterFn: (value: any) => void;
}>();
const optionsWithActive = computed(() => {
return props.options?.map((option) => {
return {
...option,
active: option.value === props.source(),
};
});
return props.options?.map(option => {
return {
...option,
active: option.value === props.source(),
};
});
});
</script>
<style lang="scss">
.setting-select {
display: flex;
background-color: $gray3;
margin-left: 8px;
display: flex;
background-color: $gray3;
.option {
font-weight: 500;
padding: 0.5rem;
cursor: pointer;
user-select: none;
min-width: 4rem;
text-align: center;
transition: background-color 0.2s ease-out;
}
.option {
font-weight: 500;
padding: 0.5rem;
cursor: pointer;
user-select: none;
min-width: 4rem;
text-align: center;
transition: background-color 0.2s ease-out;
}
.option.active {
background-color: $darkestblue;
}
.option.active {
background-color: $darkestblue;
}
}
</style>

View File

@@ -1,203 +1,222 @@
<template>
<div v-if="group && (group.show_if ? group.show_if() : true)" class="settingsgroup">
<!-- <div v-if="group.title || group.desc" class="info">
<h4 v-if="group.title">
{{ group.title
}}<span v-if="group.experimental" class="badge experimental circular">
{{ group.experimental ? "experimental" : "" }}
</span>
</h4>
<div v-if="group.desc" class="desc">{{ group.desc }}</div>
</div> -->
<div class="setting pad-lg">
<div
v-for="(setting, index) in group.settings.filter((s) => (s.show_if ? s.show_if() : true))"
:key="index"
class="setting-item"
:class="{
inactive: setting.inactive && setting.inactive(),
'is-list': setting.type === SettingType.root_dirs,
}"
>
<div class="text" @click="setting.defaultAction ? setting.defaultAction() : setting.action()">
<div class="title">
<span class="ellip">
{{ setting.title }}
<span v-if="setting.experimental" class="badge experimental circular">
{{ setting.experimental ? "experimental" : "" }}
</span>
<span v-if="setting.new" class="badge new circular">
{{ setting.new ? "new" : "" }}
</span>
</span>
<button v-if="setting.type == SettingType.root_dirs" @click="setting.action"><ReloadSvg /> rescan</button>
</div>
<div v-if="setting.desc" class="desc">
{{ setting.desc }}
</div>
</div>
<div class="options">
<Switch
v-if="setting.type == SettingType.binary"
:state="setting.state && setting.state()"
@click="setting.action()"
/>
<Select
v-if="setting.type === SettingType.select"
:options="setting.options"
:source="setting.state !== null ? setting.state : () => ''"
:setter-fn="setting.action"
/>
<button v-if="setting.type === SettingType.button" @click="setting.action">
{{ setting.button_text && setting.button_text() }}
</button>
<LockedNumberInput
v-if="setting.type == SettingType.locked_number_input"
:value="setting.state !== null ? setting.state() : 0"
:min="0"
:max="10"
:step="1"
:unit="'s'"
:on-change="setting.action"
/>
</div>
<div v-if="group && (group.show_if ? group.show_if() : true)" class="settingsgroup">
<div class="setting pad-lg">
<div
v-for="(setting, index) in group.settings.filter(s => (s.show_if ? s.show_if() : true))"
:key="index"
class="setting-item"
:class="{
inactive: setting.inactive && setting.inactive(),
'is-list': setting.type === SettingType.root_dirs,
}"
>
<div class="text" @click="setting.defaultAction ? setting.defaultAction() : setting.action()">
<div class="title">
<span class="ellip">
{{ setting.title }}
<span v-if="setting.experimental" class="badge experimental circular">
{{ setting.experimental ? 'experimental' : '' }}
</span>
<span v-if="setting.new" class="badge new circular">
{{ setting.new ? 'new' : '' }}
</span>
</span>
<button v-if="setting.type == SettingType.root_dirs" @click="setting.action">
<ReloadSvg height="1.5rem" /> <span>Rescan</span>
</button>
</div>
<div v-if="setting.desc" class="desc">
{{ setting.desc }}
</div>
</div>
<div class="options">
<Switch
v-if="setting.type == SettingType.binary"
:state="setting.state && setting.state()"
@click="setting.action()"
/>
<Select
v-if="setting.type === SettingType.select"
:options="setting.options"
:source="setting.state !== null ? setting.state : () => ''"
:setter-fn="setting.action"
/>
<NumberInput
v-if="setting.type === SettingType.free_number_input"
:value="setting.state && setting.state()"
:callback="setting.action"
/>
<button v-if="setting.type === SettingType.button" @click="setting.action">
{{ setting.button_text && setting.button_text() }}
</button>
<LockedNumberInput
v-if="setting.type == SettingType.locked_number_input"
:value="setting.state !== null ? setting.state() : 0"
:min="0"
:max="10"
:step="1"
:unit="'s'"
:on-change="setting.action"
/>
</div>
<!-- Custom components -->
<List
v-if="setting.type === SettingType.root_dirs"
icon="folder"
:items="setting.state !== null ? setting.state() : []"
/>
<SeparatorsInput
v-if="setting.type === SettingType.separators_input && setting.action"
:submit="setting.action"
:default="setting.state ? setting.state() : []"
/>
<Profile v-if="setting.type === SettingType.profile"/>
<Accounts v-if="setting.type === SettingType.accounts"/>
<About v-if="setting.type === SettingType.about"/>
</div>
<!-- Custom components -->
<List
v-if="setting.type === SettingType.root_dirs"
icon="folder"
:items="setting.state !== null ? setting.state() : []"
/>
<SeparatorsInput
v-if="setting.type === SettingType.separators_input && setting.action"
:submit="setting.action"
:default="setting.state ? setting.state() : []"
/>
<Profile v-if="setting.type === SettingType.profile" />
<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" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SettingGroup } from "@/interfaces/settings";
import { SettingType } from "@/settings/enums";
import { SettingGroup } from '@/interfaces/settings'
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 Select from "./Components/Select.vue";
import SeparatorsInput from "./Components/SeparatorsInput.vue";
import Switch from "./Components/Switch.vue";
import ReloadSvg from '@/assets/icons/reload.svg'
import List from './Components/List.vue'
import LockedNumberInput from './Components/LockedNumberInput.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 Accounts from "../modals/settings/custom/Accounts.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 DropDown from '../shared/DropDown.vue'
import settings from '@/settings'
import BackupRestore from './Components/BackupRestore.vue'
defineProps<{
group: SettingGroup;
}>();
group: SettingGroup
}>()
</script>
<style lang="scss">
.settingsgroup {
display: grid;
gap: $small;
margin-top: 2rem;
padding-bottom: 2rem;
&:first-child {
margin-top: 0;
}
.info {
margin-left: $smaller;
margin-bottom: $small;
}
h4 {
margin: $small auto;
}
.desc {
opacity: 0.5;
font-size: 0.8rem;
font-weight: 500;
}
.setting {
// background-color: $gray;
.inactive {
opacity: 0.5;
pointer-events: none;
}
}
.setting > * {
display: grid;
grid-template-columns: 1fr max-content;
}
gap: $small;
margin-top: 2rem;
padding-bottom: 2rem;
.setting-item {
user-select: none;
border-bottom: solid 1px $gray5;
padding: 1.25rem 0;
.options {
margin: auto 0;
&:first-child {
margin-top: 0;
}
.text {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: self-start;
width: 100%;
.info {
margin-left: $smaller;
margin-bottom: $small;
}
.title {
h4 {
margin: $small auto;
}
.desc {
opacity: 0.5;
font-size: 0.8rem;
font-weight: 500;
margin: auto 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: $small;
width: 100%;
}
button > svg {
transform: scale(0.65);
.setting {
// background-color: $gray;
@include mediumPhones {
padding: 1rem $small;
}
}
.desc {
margin-top: $smaller;
}
.inactive {
opacity: 0.5;
pointer-events: none;
}
}
}
.setting-item:first-child {
padding-top: 0;
}
.setting > * {
display: grid;
grid-template-columns: 1fr max-content;
gap: $small;
.setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
@include smallerPhones {
.info ~ .setting > .setting-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: $small;
.options > .setting-select {
margin-left: unset;
}
@include smallPhones {
display: flex;
flex-wrap: wrap;
}
}
.setting-item {
user-select: none;
border-bottom: solid 1px $gray5;
padding: 1.25rem 0;
.options {
margin: auto 0;
}
.text {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: self-start;
width: 100%;
.title {
font-weight: 500;
margin: auto 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: $small;
width: 100%;
button > svg {
transform: scale(0.65);
}
}
.desc {
margin-top: $smaller;
}
}
}
.setting-item:first-child {
padding-top: 0;
}
.setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
@include smallerPhones {
.info ~ .setting > .setting-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: $small;
}
}
}
}
</style>

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,168 @@
<template>
<div class="chartgroup rounded" :class="group">
<ChartsHeader :name="group" @change-period="changePeriod" @change-group="changeGroup" :period="period" />
<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 {{ group.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="(group.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 ChartItem from './ChartItem.vue'
import ChartsHeader from './ChartsHeader.vue'
import ArrowSvg from '@/assets/icons/arrow.svg'
import CalendarSvg from '@/assets/icons/calendar.svg'
// Reactive variables
const loading = ref(true)
const loaded = ref(false)
const group = ref('artists')
const period = ref('week')
const items2: any = reactive({
tracks: <Track[]>[],
albums: <Album[]>[],
artists: <Artist[]>[],
})
const items = computed(() => {
return items2[group.value]
})
const scrobbleInfo = ref<{
text: string
trend: string
dates: string
} | null>(null)
// Functions
async function getItems() {
items2[group.value] = []
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(group.value, period.value, 10, 'playduration')
items2[group.value] = res.data[group.value]
scrobbleInfo.value = res.data.scrobbles
loaded.value = true
} finally {
isPending = false
loading.value = false
loaded.value = true
}
}
async function changePeriod(newPeriod: string) {
period.value = newPeriod
await getItems()
}
async function changeGroup(newGroup: string) {
group.value = 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,188 @@
<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 !== 'toptrack'" />
<router-link
:to="{
name: Routes.album,
params: {
albumhash: props.image?.replace('.webp', ''),
},
}"
v-if="props.icon === 'toptrack' && 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 { 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 HeadphoneSvg
}
})
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 {
aspect-ratio: 1.5;
}
</style>

View File

@@ -0,0 +1,83 @@
<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"
/>
</div>
</div>
<div class="statsdates">
<CalendarSvg />
{{ dates }}
</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 statItems = ref<StatItem[]>([])
const dates = ref<string[]>([])
onMounted(async () => {
const res = await getStats()
if (res.status == 200) {
statItems.value = res.data.stats
dates.value = res.data.dates
}
})
</script>
<style lang="scss">
.statshead {
display: grid;
grid-template-columns: 1fr max-content;
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

@@ -1,13 +1,7 @@
<template>
<!-- 👇 login modal should not be dismissable -->
<div
v-if="modal.visible || modal.component == ModalOptions.login"
class="modal"
>
<div
class="bg"
@click="modal.hideModal"
></div>
<div v-if="modal.visible || modal.component == ModalOptions.login" class="modal">
<div class="bg" @click="modal.hideModal"></div>
<div
v-motion-slide-top
class="m-content rounded"
@@ -16,10 +10,7 @@
authlogin: modal.component == modal.options.login,
}"
:style="{
maxWidth:
modal.component == modal.options.setRootDirs
? '56rem'
: '30rem',
maxWidth: modal.component == modal.options.setRootDirs ? '56rem' : '30rem',
}"
>
<!-- TODO: MOVE MAX WIDTH TO CLASS -->
@@ -44,18 +35,9 @@
:confirm-action="deletePlaylist"
/>
</div>
<SetRootDirs
v-if="modal.component == modal.options.setRootDirs"
@hideModal="hideModal"
/>
<RootDirsPrompt
v-if="modal.component == modal.options.rootDirsPrompt"
@hideModal="hideModal"
/>
<Settings
@set-title="setTitle"
v-if="modal.component == modal.options.settings"
/>
<SetRootDirs v-if="modal.component == modal.options.setRootDirs" @hideModal="hideModal" />
<RootDirsPrompt v-if="modal.component == modal.options.rootDirsPrompt" @hideModal="hideModal" />
<Settings @set-title="setTitle" v-if="modal.component == modal.options.settings" />
</div>
</div>
</template>
@@ -70,8 +52,8 @@ 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 UpdatePlaylist from './modals/updatePlaylist.vue'
import Settings from './modals/Settings.vue'
import UpdatePlaylist from './modals/updatePlaylist.vue'
const modal = useModalStore()
const router = useRouter()
@@ -94,7 +76,7 @@ function deletePlaylist() {
<style lang="scss">
.modal {
position: fixed;
z-index: 20;
z-index: 21;
height: 100vh;
width: 100vw;
display: grid;
@@ -119,19 +101,24 @@ function deletePlaylist() {
left: 0;
width: 100%;
height: 100%;
background-color: rgba(22, 22, 22, 0.753);
// opacity: 0;
// visibility: hidden;
background-color: rgb(0, 0, 0, 0.6);
// transition: opacity 300ms ease, visibility 300ms ease;
//background-color: rgba(22, 22, 22, 0.8);
// backdrop-filter: blur(5px);
}
.m-content {
width: calc(100% - 4rem);
max-height: 40rem;
max-height: calc(100% - 4rem);
padding: 2rem 1.25rem;
position: relative;
background-color: $black;
@include largePhones {
@include allPhones {
width: calc(100% - 2rem);
max-height: calc(100% - 2rem);
padding: 2rem 1rem;
}
}

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,15 +238,23 @@ onMounted(async () => {
align-items: center;
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-size: 1rem;
font-weight: 500;
width: 100%;
height: 3rem;
padding: 1rem;
font-size: 1rem;
border: none;
outline: none;
background-color: $gray5;
color: $gray1;
color: $white;
text-align: center;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.submit {
@@ -299,6 +264,11 @@ onMounted(async () => {
height: 3rem;
background-color: $darkblue;
margin-top: 1rem;
transition: color 0.2s ease-out;
&:hover {
color: #ffffff;
}
}
.submit.long {

View File

@@ -66,8 +66,9 @@ function fetchDirs(path: string) {
.then((folders) => {
dirs.value = folders;
no_more_dirs.value = folders.length == 0;
[oldpath, subPaths.value] = createSubPaths(path, oldpath);
const res = createSubPaths(path, oldpath);
oldpath = res[0];
subPaths.value = res[1];
})
.then(() => (current = path == "$home" ? "" : path));
}

View File

@@ -1,20 +1,24 @@
<template>
<div class="settingsmodal">
<div
class="settingsmodal"
:class="{
isSmallPhone,
}"
v-auto-animate
>
<Sidebar
:current-group="(currentGroup as SettingGroup)"
@set-tab="(tab) => (currentTab = tab)"
@set-tab="tab => (currentTab = tab)"
v-if="!(isSmallPhone && showContent)"
/>
<div class="content">
<div
class="head"
v-auto-animate
>
<div class="content" v-if="showContent">
<div class="head" v-auto-animate>
<div class="h2">
<button class="back" v-if="isSmallPhone" @click="handleGoBack">
<ArrowSvg />
</button>
{{ currentGroup?.title }}
<span
v-if="currentGroup?.experimental"
class="badge experimental circular"
>
<span v-if="currentGroup?.experimental" class="badge experimental circular">
{{ currentGroup?.experimental ? 'experimental' : '' }}
</span>
</div>
@@ -27,10 +31,12 @@
<script setup lang="ts">
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 { computed, ref } from 'vue'
import { SettingGroup } from '@/interfaces/settings'
const emit = defineEmits<{
(e: 'setTitle', title: string): void
@@ -46,28 +52,39 @@ const currentGroup = computed(() => {
}
}
if (isSmallPhone.value) {
return null
}
// select default tab
for (const group of settingGroups) {
for (const settings of group.groups) {
if (settings.title === 'Appearance') {
if (settings.title === 'Backup') {
return settings
}
}
}
})
const showContent = computed(() => {
return currentGroup.value !== null
})
function handleGoBack() {
currentTab.value = ''
}
</script>
<style lang="scss">
$modalheight: 35rem;
$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;
@@ -76,10 +93,23 @@ $modalheight: 35rem;
flex-direction: column;
justify-content: center;
@include mediumPhones {
padding: 0 1.75rem;
}
.h2 {
margin: 0;
font-size: 1.15rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 1rem;
}
.back {
margin-left: -1rem;
background-color: transparent;
}
.desc {
@@ -89,7 +119,6 @@ $modalheight: 35rem;
}
}
// Role badges used in Profile and Accounts tabs
.roles {
display: flex;
@@ -97,7 +126,7 @@ $modalheight: 35rem;
.role {
// margin: $smaller $small 0 0;
padding: 3px $smaller;
padding: 2px $smaller;
border-radius: $smaller;
border: solid 1px $brown;
color: $brown;
@@ -111,4 +140,12 @@ $modalheight: 35rem;
}
}
}
.settingsmodal.isSmallPhone {
grid-template-columns: 1fr;
.settingssidebar {
border-right: none;
}
}
</style>

View File

@@ -1,15 +1,15 @@
<template>
<div class="settingsmodalcontent">
<Group :group="settings"/>
<Group :group="settings" />
</div>
</template>
<script setup lang="ts">
import { SettingGroup } from '@/interfaces/settings';
import Group from '@/components/SettingsView/Group.vue';
import Group from "@/components/SettingsView/Group.vue";
import { SettingGroup } from "@/interfaces/settings";
defineProps<{
settings: SettingGroup
settings: SettingGroup;
}>();
</script>
@@ -18,7 +18,14 @@ defineProps<{
width: 100%;
padding: 0 1rem;
height: 100%;
max-height: calc(100vh - 6.85rem);
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
@include allPhones {
max-height: calc(100vh - 4.85rem);
}
}
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="profilesettings">
<div class="avatar">
<div class="profileavatar">
<Avatar :name="username || auth.user.username" />
<div class="name">
{{
adding_user
? username
: `Hi ${auth.user.firstname || auth.user.username}`
: `Hi ${auth.user.username}`
}}
</div>
<div
@@ -66,12 +66,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import useAuth from '@/stores/auth'
import Avatar from '@/components/shared/Avatar.vue'
import Input from '@/components/shared/Input.vue'
import { User } from '@/interfaces'
import useAuth from '@/stores/auth'
const props = defineProps<{
adding_user?: boolean
@@ -170,7 +170,7 @@ onMounted(async () => {
<style lang="scss">
.profilesettings {
.avatar {
.profileavatar {
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -3,16 +3,13 @@
<div class="groups">
<div
class="group"
v-for="group in settingGroups.filter((g) => {
v-for="group in settingGroups.filter(g => {
// return true
return g.show_if ? g.show_if() : true
})"
:key="group.title"
>
<div
class="gtitle"
v-if="group.title"
>
<div class="gtitle" v-if="group.title">
{{ group.title }}
</div>
<div class="gitems">
@@ -21,21 +18,13 @@
v-for="item in group.groups"
:key="item.title"
:class="{
active: item.title === currentGroup.title,
active: currentGroup && item.title === currentGroup.title,
about: item.title === 'About',
}"
@click="() => $emit('setTab', item.title || '')"
>
<Avatar
:size="18"
:name="auth.user.username || ''"
v-if="item.title === 'Profile'"
/>
<span
class="icon"
v-html="item.icon"
v-else
></span>
<Avatar :size="18" :name="auth.user.username || ''" v-if="item.title === 'Profile'" />
<span class="icon" v-html="item.icon" v-else></span>
<span>
{{ item.title }}
</span>
@@ -47,9 +36,9 @@
</template>
<script setup lang="ts">
import useAuth from '@/stores/auth'
import settingGroups from '@/settings'
import { SettingGroup } from '@/interfaces/settings'
import settingGroups from '@/settings'
import useAuth from '@/stores/auth'
import Avatar from '@/components/shared/Avatar.vue'
@@ -74,6 +63,34 @@ defineEmits<{
grid-template-rows: 1fr max-content;
user-select: none;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
max-height: calc(100vh - 4rem);
@include allPhones {
max-height: calc(100vh - 2rem);
}
@include largePhones {
padding: 1rem;
}
.groups {
display: flex;
flex-direction: column;
.group {
&:first-child {
.gitems {
.gitem {
margin-top: 0;
}
}
}
}
}
.appversion {
pointer-events: none;
font-size: 12px;
@@ -84,7 +101,7 @@ defineEmits<{
.gtitle {
font-weight: bold;
font-size: 14px;
margin: $medium 0 $small $small;
margin: 1.25rem 0 $smaller $small;
}
.gitems {
@@ -103,9 +120,15 @@ defineEmits<{
font-size: 14px;
margin-top: $smaller;
position: relative;
transition: background-color 0.2s ease-out, color 0.2s ease-out;
@include largePhones {
padding: 0.551rem;
}
svg {
width: 1.25rem;
transition: color 0.2s ease-out;
}
.icon {
@@ -127,7 +150,7 @@ defineEmits<{
}
&.about {
margin-top: 1rem;
margin-top: 14px;
}
&.about::before {

View File

@@ -1,13 +1,6 @@
<template>
<Profile
:adding_user="true"
v-if="showAddUser"
@user-added="userAdded"
/>
<div
class="accountsettings"
v-else
>
<Profile :adding_user="true" v-if="showAddUser" @user-added="userAdded" />
<div class="accountsettings" v-else>
<div class="asettings">
<ToggleSetting
v-for="s in account_settings"
@@ -20,10 +13,7 @@
</div>
<div class="ahead">
<div class="h2">All users</div>
<button
class="adduser"
@click="showAddUser = true"
>
<button class="adduser" @click="showAddUser = true">
<PlusSvg />
new user
</button>
@@ -36,28 +26,17 @@
:key="user.id"
:class="{
selected: user.id === selectedUser,
firstchild: index == 0,
secondchild: index == 1,
}"
>
<div
class="userinfo"
@click="() => selectUser(user.id)"
>
<Avatar
:name="user.username"
:size="47"
/>
<div class="userinfo" @click="() => selectUser(user.id)">
<Avatar :name="user.username" :size="47" />
<div class="details">
<div class="name">
{{ user.firstname || user.username }}
</div>
<div class="roles">
<span
class="role"
v-for="role in user.roles"
>{{ role }}</span
>
<span class="role" v-for="role in user.roles">{{ role }}</span>
</div>
</div>
<DeleteSvg
@@ -66,30 +45,20 @@
@click.stop="() => deleteUser(user)"
/>
</div>
<div
class="usettins"
v-if="user.id === selectedUser"
>
<div class="usettins" v-if="user.id === selectedUser">
<ToggleSetting
v-for="setting in usettings.filter((s) => {
// if there's only one admin and it's the current user
// don't show the admin setting
if (
s.title === 'Admin' &&
users.filter((u) => u.roles.includes('admin'))
.length === 1 &&
user.roles.includes('admin') &&
user.username === auth.user.username
) {
return false
}
return true
})"
v-for="setting in usettings"
:key="setting.title"
:title="setting.title"
:desc="setting.desc"
:value="setting.value(user.roles)"
:class="{
disabled:
setting.title === 'Admin' &&
users.filter(u => u.roles.includes('admin')).length === 1 &&
user.roles.includes('admin') &&
user.username === auth.user.username,
}"
@click="() => setting.action(user)"
/>
</div>
@@ -99,151 +68,145 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { User } from '@/interfaces'
import { getAllUsers } from '@/requests/auth'
import { SettingType } from '@/settings/enums'
import { updateConfig } from '@/requests/settings'
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 Profile from '../Profile.vue'
import PlusSvg from '@/assets/icons/plus.svg'
import ToggleSetting from './ToggleSetting.vue'
import Avatar from '@/components/shared/Avatar.vue'
import DeleteSvg from '@/assets/icons/delete.svg'
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) {
@@ -254,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">
@@ -319,6 +282,11 @@ onMounted(async () => {
padding-bottom: 0;
margin-top: 1rem;
border: solid 1px $gray5;
cursor: pointer;
&:hover {
background-color: $gray5;
}
.userinfo {
display: grid;
@@ -352,16 +320,11 @@ onMounted(async () => {
}
}
.usercard.firstchild {
background-color: $gray5;
border: none;
}
.usercard.secondchild {
margin-top: 1.75rem !important;
&::before {
content: '';
content: "";
position: absolute;
top: -1rem;
left: 45%;
@@ -387,6 +350,10 @@ onMounted(async () => {
font-size: 14px;
margin-top: $small;
}
.togglesetting:last-child {
padding-bottom: 0;
}
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="pairing">
<div
ref="qrcode"
class="qrcode"
v-if="qrLoaded"
></div>
<div
class="loader"
v-if="!qrLoaded"
>
<div class="spinner"></div>
</div>
<p class="desc">
Scan the QR code from the Swing Music app to pair with this server.
</p>
<div class="serverurl rounded">{{ url }}</div>
</div>
</template>
<script setup lang="ts">
import { Ref, onMounted, ref } from 'vue'
import QRCodeStyling from 'qr-code-styling'
import { sendPairRequest } from '@/requests/auth'
const qrLoaded = ref(false)
// @ts-expect-error
const qrcode: Ref<HTMLElement> = ref(null)
const url = window.location.origin
async function renderQrCode(code: string) {
const data = window.location.origin + ' ' + code
const qrCode = new QRCodeStyling({
width: 300,
height: 300,
type: 'svg',
data: data,
image: '/logo-fill.light.svg',
dotsOptions: {
color: '#fff',
type: 'extra-rounded',
},
backgroundOptions: {
color: 'transparent',
},
imageOptions: {
crossOrigin: 'anonymous',
margin: 20,
},
margin: 20,
})
const svgBlob = await qrCode.getRawData('svg')
if (!svgBlob) return
const img = document.createElement('img')
img.src = URL.createObjectURL(svgBlob)
qrcode.value.appendChild(img)
}
onMounted(async () => {
const res = await sendPairRequest()
if (res.status == 200) {
qrLoaded.value = true
return renderQrCode(res.data.code)
}
const error = document.createElement('p')
error.innerHTML = 'Error fetching pairing code. Error code: ' + res.status
qrcode.value.appendChild(error)
})
</script>
<style lang="scss">
.pairing {
text-align: center;
.qrcode,
.loader {
height: 300px;
}
.loader {
display: grid;
place-items: center;
}
.spinner {
border-color: transparent;
border-top-color: $gray1;
margin: 0 auto;
}
.serverurl {
// background-color: $orange;
width: fit-content;
margin: 0 auto;
padding: $smaller;
font-size: 12px;
font-family: 'SF Mono';
color: $orange;
}
}
</style>

View File

@@ -36,6 +36,11 @@ defineEmits<{
align-items: center;
padding: $small;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:hover {
background-color: $gray5;
}

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,133 +160,142 @@ 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;
}
#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;
#update-pl-img-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: $small;
object-fit: cover;
background-color: $gray4;
position: relative;
&::placeholder {
color: #d1d1d1;
opacity: 0.5;
}
}
.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;
}
.boxed {
border: solid 2px $gray4;
color: $gray1;
place-items: center;
display: grid;
grid-template-columns: 1fr max-content;
}
.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;
.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;
}
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);
}
&: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

@@ -0,0 +1,71 @@
<template>
<div class="avatar">
<div class="img circular">
<Avatar :name="auth.user.username || ''" :size="36" />
</div>
<ProfileDropdown />
</div>
</template>
<script setup lang="ts">
import useAuth from '@/stores/auth';
const auth = useAuth()
import Avatar from '../shared/Avatar.vue';
import ProfileDropdown from './ProfileDropdown.vue';
</script>
<style lang="scss">
.avatar {
position: relative;
aspect-ratio: 1;
cursor: pointer;
transition: background-color 0.2s ease-out, color 0.2s ease-out;
display: grid;
place-items: center;
border-radius: 40%;
.img {
height: 36px;
&::after {
content: '';
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: #00000000;
border-radius: 5rem;
transition: all 0.75s ease-out;
}
&:hover {
&::after {
background-color: $brown;
}
}
}
.profiledrop {
opacity: 0;
visibility: hidden;
transform: translateY(0.5rem);
transition: opacity 0.2s ease-out, visibility 0.2s ease-out, transform 0.2s ease-out;
}
&:hover {
.profiledrop {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
@include allPhones {
height: unset;
background-color: transparent;
}
}
</style>

View File

@@ -1,115 +1,59 @@
<template>
<div
class="topnav"
:class="{
use_links: settings.is_alt_layout,
use_sidebar: settings.use_sidebar && isSmall,
}"
>
<div class="left">
<NavButtons />
<NavLinks v-if="settings.is_alt_layout" />
<div v-if="settings.is_default_layout && $route.name == Routes.folder" class="info">
<Folder :sub-paths="subPaths" />
</div>
<NavTitles v-else-if="settings.is_default_layout && !isSmall" />
</div>
<div class="sidenav_toggle" @click="toggleSidenav">
<div class="bar"></div>
<div class="bar"></div>
</div>
<NavSidenav @close="toggleSidenav" :class="{ active: sidenavActive }" />
<div class="dimmer noSelect" :class="{ active: sidenavActive }" @click="toggleSidenav"></div>
<RouterLink v-if="settings.is_alt_layout" to="/" class="logo rounded-sm"><LogoSvg /></RouterLink>
<div v-if="settings.is_alt_layout || !settings.use_sidebar || !xl" class="right">
<SearchInput :on_nav="true" />
<!-- v-if="settings.is_alt_layout" -->
<div class="avatar">
<div class="img circular">
<Avatar
:name="auth.user.username || ''"
:size="36"
/>
</div>
<ProfileDropdown />
<div
class="topnav"
:class="{
use_links: settings.is_alt_layout,
use_sidebar: settings.use_sidebar && isSmall,
}"
>
<div class="left">
<NavButtons />
<NavLinks v-if="settings.is_alt_layout" />
<div v-if="settings.is_default_layout && $route.name == Routes.folder" class="info">
<Folder />
</div>
<NavTitles v-else-if="settings.is_default_layout && !isSmall" />
</div>
<div class="sidenav_toggle" @click="toggleSidenav">
<div class="bar"></div>
<div class="bar"></div>
</div>
<NavSidenav @close="toggleSidenav" :class="{ active: sidenavActive }" />
<div class="dimmer noSelect" :class="{ active: sidenavActive }" @click="toggleSidenav"></div>
<RouterLink v-if="settings.is_alt_layout" to="/" class="logo rounded-sm"><LogoSvg /></RouterLink>
<div v-if="settings.is_alt_layout || !settings.use_sidebar || !xl" class="right">
<SearchInput :on_nav="true" />
<AvatarWithDropdown />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Routes } from '@/router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { computed, ref } from 'vue'
import { subPath } from '@/interfaces'
import useAuth from '@/stores/auth'
import { content_width } from '@/stores/content-width'
import useSettings from '@/stores/settings'
import { createSubPaths } from '@/utils'
import useAuth from '@/stores/auth'
import { xl } from "./../../composables/useBreakpoints";
import { xl } from './../../composables/useBreakpoints'
import LogoSvg from '@/assets/icons/logos/logo-fill.light.svg'
import SearchInput from '../RightSideBar/SearchInput.vue'
import NavButtons from './NavButtons.vue'
import NavLinks from './NavLinks.vue'
import NavSidenav from "./NavSidenav.vue";
import NavSidenav from './NavSidenav.vue'
import NavTitles from './NavTitles.vue'
import Avatar from '../shared/Avatar.vue'
import Folder from './Titles/Folder.vue'
import ProfileDropdown from './ProfileDropdown.vue'
import AvatarWithDropdown from './AvatarWithDropdown.vue'
const auth = useAuth()
const settings = useSettings()
const isSmall = computed(() => content_width.value < 800)
const route = useRoute()
const subPaths = ref<subPath[]>([])
let oldpath = ''
watch(
() => route.name,
(newRoute) => {
switch (newRoute) {
case Routes.folder: {
;[oldpath, subPaths.value] = createSubPaths(
route.params.path as string,
oldpath
)
watch(
() => route.params.path,
(newPath) => {
newPath = newPath as string
if (newPath == undefined) return
;[oldpath, subPaths.value] = createSubPaths(
newPath,
oldpath
)
}
)
break
}
default:
break
}
}
)
onMounted(() => {
if (route.name == Routes.folder) {
;[oldpath, subPaths.value] = createSubPaths(
route.params.path as string,
oldpath
)
}
})
const sidenavActive = ref(false);
const sidenavActive = ref(false)
function toggleSidenav() {
sidenavActive.value = !sidenavActive.value;
sidenavActive.value = !sidenavActive.value
}
</script>
@@ -126,33 +70,40 @@ function toggleSidenav() {
gap: 1rem;
font-size: 14px;
&.use_links {
grid-template-columns: 1fr max-content 1fr;
}
&.use_links {
grid-template-columns: 1fr max-content 1fr;
}
.left {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
position: relative;
.info {
margin: auto 0;
width: fit-content;
overflow: hidden;
.info {
margin: auto 0;
width: fit-content;
overflow: hidden;
.title {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
}
.title {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
}
}
@include allPhones {
display: none;
}
// INFO: Folder page sort bar overrides
.sortbar {
top: 0 !important;
right: 0 !important;
}
}
@include allPhones {
display: none;
}
}
.logo {
width: max-content;
margin: 0 auto;
@@ -168,69 +119,14 @@ function toggleSidenav() {
align-items: center;
width: 100%;
.avatar {
position: relative;
aspect-ratio: 1;
cursor: pointer;
transition: background-color 0.2s ease-out, color 0.2s ease-out;
display: grid;
place-items: center;
border-radius: 40%;
.img {
height: 36px;
&::after {
content: '';
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: #00000000;
border-radius: 5rem;
transition: all 0.75s ease-out;
}
&:hover {
&::after {
background-color: $brown;
}
}
}
.profiledrop {
opacity: 0;
transform: translateY(0.5rem);
transition: all 0.25s ease-out;
visibility: hidden;
transition-delay: 0.35s;
}
&:hover {
.profiledrop {
visibility: visible;
opacity: 1;
transform: translateY(0);
transition-delay: 0.15s;
}
}
@include allPhones {
height: unset;
background-color: transparent;
}
@include allPhones {
gap: unset;
justify-content: unset;
}
}
@include allPhones {
gap: unset;
justify-content: unset;
}
}
@include allPhones {
display: flex;
display: flex;
}
}
</style>

View File

@@ -1,49 +1,54 @@
<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: 1rem;
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.25rem;
height: 2.25rem;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 5rem;
background-color: $gray5;
svg {
transform: scale(1.12);
transition: transform 0.2s ease;
&:hover {
background-color: $gray4;
}
&:active {
transform: scale(0.88);
}
svg {
transform: scale(1.12);
transition: transform 0.2s ease;
&:active {
transform: scale(0.88);
}
}
}
}
.back {
transform: rotate(180deg);
}
.back {
transform: rotate(180deg);
}
}
</style>

View File

@@ -28,8 +28,6 @@
import LogoSvg from "@/assets/icons/logos/logo-fill.light.svg";
import { topnavitems } from "../LeftSidebar/navitems";
import { defineEmits } from "vue";
const emit = defineEmits(["close"]);
function closeSidenav() {
@@ -130,6 +128,10 @@ function closeSidenav() {
padding: $small $medium;
cursor: pointer;
}
svg {
height: 1.5rem;
}
}
.sidenav_footer {

View File

@@ -7,18 +7,6 @@
v-if="$route.name == Routes.artistTracks"
:text="$route.query.artist as string || 'Artist Tracks'"
/>
<SimpleNav
v-if="$route.name === Routes.favoriteAlbums"
:text="'Favorite Albums'"
/>
<SimpleNav
v-if="$route.name === Routes.favoriteArtists"
:text="'Favorite Artists'"
/>
<SimpleNav
v-if="$route.name === Routes.favoriteTracks"
:text="'Favorite Tracks'"
/>
<SimpleNav v-if="$route.name === Routes.nowPlaying" :text="'Now Playing'" />
</div>
</template>

View File

@@ -1,19 +1,33 @@
<template>
<div class="profiledrop rounded-sm pad-sm shadow-lg">
<div class="info item">
Hi {{ auth.user.firstname || auth.user.username }}
<div class="username ellip2">Hi {{ auth.user.firstname || auth.user.username }}</div>
</div>
<div class="separator"></div>
<div class="item">Profile</div>
<div class="item" @click="modal.showSettingsModal">Settings</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" @click="auth.logout">Logout</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 useModal from '@/stores/modal'
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()
@@ -22,15 +36,16 @@ const modal = useModal()
<style lang="scss">
.profiledrop {
position: absolute;
background-color: $gray;
z-index: 10;
top: 2.25rem;
width: 10rem;
right: 0;
font-size: 1rem;
width: 10rem;
font-size: 0.95rem;
font-weight: 400;
display: flex;
flex-direction: column;
border: solid 1px $gray5;
z-index: 10;
background-color: $gray;
.separator {
height: 1px;
@@ -39,21 +54,66 @@ const modal = useModal()
}
.item {
padding: $small;
border-radius: $smaller;
cursor: pointer !important;
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.scan {
margin-bottom: $smaller;
}
.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 {
pointer-events: none;
flex-direction: column;
align-items: baseline;
gap: $smallest;
cursor: auto;
padding: 0.25rem 0.75rem;
&:hover {
background-color: transparent;
}
.username {
font-weight: 500;
}
}
.critical {
color: $red;
}
.critical:hover {
background-color: $red;
background-color: transparent;
outline: solid 1px;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div id="folder-nav-title">
<div class="fname">
<div
class="icon image"
class="icon"
@click="
$router.push({
name: Routes.folder,
@@ -12,82 +12,132 @@
>
<FolderSvg />
</div>
<BreadCrumbNav
:sub-paths="subPaths"
@navigate="navigate"
/>
<BreadCrumbNav @navigate="navigate" />
</div>
<DropDown
:items="items"
:current="current"
component_key="sortbar"
:reverse="folder.trackSortReverse"
@item-clicked="handleSortKeySet"
/>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { subPath } from '@/interfaces'
import { Routes } from '@/router'
import BreadCrumbNav from '@/components/FolderView/BreadCrumbNav.vue'
import FolderSvg from '@/assets/icons/folder.svg'
import BreadCrumbNav from '@/components/FolderView/BreadCrumbNav.vue'
import DropDown from '@/components/shared/DropDown.vue'
import useFolder from '@/stores/pages/folder'
import { computed } from 'vue'
const router = useRouter()
defineProps<{
subPaths: subPath[]
}>()
const folder = useFolder()
function navigate(path: string) {
router.push({ name: Routes.folder, params: { path } })
}
interface SortItem {
key: string;
title: string;
}
const items: SortItem[] = [
{ key: 'default', title: 'Default' },
{ key: 'title', title: 'Title' },
{ key: 'album', title: 'Album' },
// { key: 'albumartists', title: 'Album Artist' },
{ key: 'artists', title: 'Artist' },
// { key: 'bitrate', title: 'Bitrate' },
{ key: 'date', title: 'Release Date' },
// { key: 'disc', title: 'Disc' },
// { key: 'duration', title: 'Duration' },
{ key: 'last_mod', title: 'Date Added' },
{ key: 'lastplayed', title: 'Last Played' },
{ key: 'playcount', title: 'Play Count' },
{ key: 'playduration', title: 'Play Duration' },
]
const handleSortKeySet = (item: SortItem) => {
folder.setFolderTrackSortKey(item.key)
}
const current = computed(() => {
return items.find(item => item.key === folder.trackSortBy) || items[0]
})
</script>
<style lang="scss">
.info > #folder-nav-title {
display: grid;
}
// .info > #folder-nav-title {
// display: grid;
// }
.is_alt_layout #folder-nav-title {
display: grid;
}
// .is_alt_layout #folder-nav-title {
// display: grid;
// }
#folder-nav-title {
width: fit-content;
overflow: hidden;
display: none;
@include allPhones {
width: fit-content;
// overflow: hidden;
display: grid;
padding-top: $medium;
padding-bottom: 1rem;
}
grid-template-columns: 1fr;
// gap: 1rem;
justify-content: space-between;
// width: 100%;
margin-right: 10rem; // sortbar width
.fname {
background-color: $gray4;
border-radius: $small;
height: 2.188rem;
display: flex;
align-items: center;
max-width: 100%;
overflow: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
@include allPhones {
display: grid;
padding-top: $medium;
padding-bottom: 1rem;
.icon {
// INFO: Folder page sort bar overrides
.sortbar {
top: $medium !important;
right: 1rem !important;
}
}
.sortbar {
position: absolute;
top: 1rem;
right: 0;
width: 9rem;
}
.fname {
background-color: $gray4;
border-radius: $small;
height: 2.188rem;
display: flex;
align-items: center;
max-width: 100%;
overflow: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
.icon {
aspect-ratio: 1;
margin: $small;
margin: 0 $small;
display: flex;
svg {
height: 1.5rem;
}
}
}
}
}
.fname {
scrollbar-width: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&::-webkit-scrollbar {
display: none;
}
}
</style>

View File

@@ -1,195 +1,195 @@
<template>
<RouterLink
:to="{
name: Routes.album,
params: { albumhash: album.albumhash },
}"
class="album-card"
>
<div class="with-img rounded-sm no-scroll">
<div
class="gradient"
:style="{
background: `linear-gradient(to top, ${album.colors[0]} 20%, transparent)`,
<RouterLink
:to="{
name: Routes.album,
params: { albumhash: album.albumhash },
}"
></div>
<img class="shadow-lg" :src="imguri + album.image" alt="" />
<PlayBtn
:store="useAlbumStore"
:source="playSources.album"
:album-hash="album.albumhash"
:album-name="album.title"
/>
</div>
<div>
<div v-if="album.help_text" class="rhelp album">
<span class="help">{{ album.help_text }}</span>
<span class="time">{{ album.time }}</span>
</div>
<h4 v-tooltip class="title ellip">
{{ album.title }}
</h4>
<div class="artist ellip" @click.prevent.stop="() => {}">
<template v-if="show_date"> {{ album.date }} </template>
<span v-if="show_date && artists.length > 0"> </span>
<RouterLink
v-if="artists.length > 0"
:to="{
name: Routes.artist,
params: { hash: artists[0].artisthash },
}"
>
{{ `${artists[0].name}` }}
</RouterLink>
</div>
<div v-if="album.versions.length" class="versions">
<MasterFlag
v-for="v in getVersions(album.versions, useAlbumStore().info.versions)"
:key="v"
:bitrate="1200"
:text="v"
/>
</div>
</div>
</RouterLink>
class="album-card"
>
<div class="with-img rounded-sm no-scroll">
<div
class="gradient"
:style="{
background: `linear-gradient(to top, ${album.color} 20%, transparent)`,
}"
></div>
<img class="shadow-lg" :src="imguri + album.image" alt="" />
<PlayBtn
:store="useAlbumStore"
:source="playSources.album"
:album-hash="album.albumhash"
:album-name="album.title"
/>
</div>
<div>
<div v-if="album.help_text" class="rhelp album">
<span class="help">{{ album.help_text }}</span>
<span class="time">{{ album.time }}</span>
</div>
<h4 v-tooltip class="title ellip">
{{ album.title }}
</h4>
<div class="artist ellip" @click.prevent.stop="() => {}">
<template v-if="show_date"> {{ new Date(album.date * 1000).getFullYear() }} </template>
<span v-if="show_date && artists.length > 0"> </span>
<RouterLink
v-if="artists.length > 0"
:to="{
name: Routes.artist,
params: { hash: artists[0].artisthash },
}"
>
{{ `${artists[0].name}` }}
</RouterLink>
</div>
<div v-if="album.versions.length" class="versions">
<MasterFlag
v-for="v in getVersions(album.versions, useAlbumStore().info.versions)"
:key="v"
:bitrate="1200"
:text="v"
/>
</div>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { Routes } from "@/router";
import { computed } from "vue";
import { useRoute } from "vue-router";
import { Routes } from '@/router'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Album } from "../../interfaces";
import PlayBtn from "./PlayBtn.vue";
import { Album } from '../../interfaces'
import PlayBtn from './PlayBtn.vue'
import { playSources } from "@/enums";
import useAlbumStore from "@/stores/pages/album";
import { paths } from "../../config";
import MasterFlag from "./MasterFlag.vue";
import { playSources } from '@/enums'
import useAlbumStore from '@/stores/pages/album'
import { paths } from '../../config'
import MasterFlag from './MasterFlag.vue'
const imguri = paths.images.thumb.medium;
const route = useRoute();
const imguri = paths.images.thumb.medium
const route = useRoute()
const props = defineProps<{
album: Album;
show_date?: boolean;
artist_page?: boolean;
hide_artists?: boolean;
}>();
album: Album
show_date?: boolean
artist_page?: boolean
hide_artists?: boolean
}>()
function getVersions(ver1: string[], ver2: string[] = []) {
const diff = ver1.filter((x) => !ver2.includes(x));
const diff = ver1.filter(x => !ver2.includes(x))
if (diff.length > 0) {
return diff.slice(0, 1);
}
if (diff.length > 0) {
return diff.slice(0, 1)
}
return ver1.slice(0, 1);
return ver1.slice(0, 1)
}
const artists = computed(() => {
const albumartists = props.artist_page
? props.album.albumartists.filter((x) => x.artisthash != route.params.hash)
: props.album.albumartists;
const albumartists = props.artist_page
? props.album.albumartists.filter(x => x.artisthash != route.params.hash)
: props.album.albumartists
return albumartists;
});
return albumartists
})
</script>
<style lang="scss">
.album-card {
display: grid;
gap: $small;
padding: $medium;
border-radius: 1rem;
height: max-content;
transition: background-color 0.2s ease-out;
display: grid;
gap: $small;
padding: $medium;
border-radius: 1rem;
height: max-content;
transition: background-color 0.2s ease-out;
.with-img {
position: relative;
.with-img {
position: relative;
img {
display: block;
aspect-ratio: 1;
height: 100%;
aspect-ratio: 1;
object-fit: cover;
img {
display: block;
aspect-ratio: 1;
height: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.gradient {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
}
&:hover {
.play-btn {
transform: translateY(0);
opacity: 1;
}
img {
border-radius: 0 0 $medium $medium;
}
.gradient {
opacity: 1;
}
}
}
.gradient {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
.play-btn {
$btn-width: 4rem;
position: absolute;
bottom: 1rem;
right: calc((100% - $btn-width) / 2);
opacity: 0;
transform: translateY(1rem);
transition: all 0.25s;
width: $btn-width;
}
&:hover {
.play-btn {
transform: translateY(0);
opacity: 1;
}
img {
border-radius: 0 0 $medium $medium;
}
.gradient {
opacity: 1;
}
background-color: $gray5;
}
}
.play-btn {
$btn-width: 4rem;
position: absolute;
bottom: 1rem;
right: calc((100% - $btn-width) / 2);
opacity: 0;
transform: translateY(1rem);
transition: all 0.25s;
width: $btn-width;
}
&:hover {
background-color: $gray4;
}
img {
width: 100%;
aspect-ratio: 1;
}
h4 {
margin: 0;
}
.title {
margin-bottom: $smallest;
font-size: 0.95rem;
width: fit-content;
position: relative;
}
.artist {
font-size: 0.8rem;
text-align: left;
opacity: 0.75;
font-weight: 700;
a {
cursor: pointer !important;
&:hover {
text-decoration: underline;
}
img {
width: 100%;
aspect-ratio: 1;
}
}
.versions {
display: flex;
gap: $smaller;
margin-top: $small;
margin-left: -$smaller;
}
h4 {
margin: 0;
}
.title {
margin-bottom: $smallest;
font-size: 0.95rem;
width: fit-content;
position: relative;
}
.artist {
font-size: 0.8rem;
text-align: left;
opacity: 0.75;
font-weight: 700;
a {
cursor: pointer !important;
&:hover {
text-decoration: underline;
}
}
}
.versions {
display: flex;
gap: $smaller;
margin-top: $small;
margin-left: -$smaller;
}
}
</style>

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