feat(ui): use Tailwind CSS

This commit is contained in:
Phan An 2024-04-05 00:20:42 +02:00
parent 0b66f365b2
commit 902c439fed
296 changed files with 5204 additions and 7669 deletions

View file

@ -190,6 +190,7 @@ class SongRepository extends Repository
'collaborators.id as collaborator_id', 'collaborators.id as collaborator_id',
'collaborators.name as collaborator_name', 'collaborators.name as collaborator_name',
'collaborators.email as collaborator_email', 'collaborators.email as collaborator_email',
'collaborators.avatar as collaborator_avatar',
'playlist_song.created_at as added_at' 'playlist_song.created_at as added_at'
); );
}) })

View file

@ -17,11 +17,12 @@ abstract class CloudStorage extends SongStorage
{ {
public function __construct(protected FileScanner $scanner) public function __construct(protected FileScanner $scanner)
{ {
parent::__construct();
} }
protected function scanUploadedFile(UploadedFile $file, User $uploader): ScanResult protected function scanUploadedFile(UploadedFile $file, User $uploader): ScanResult
{ {
self::assertSupported();
// Can't scan the uploaded file directly, as it apparently causes some misbehavior during idv3 tag reading. // Can't scan the uploaded file directly, as it apparently causes some misbehavior during idv3 tag reading.
// Instead, we copy the file to the tmp directory and scan it from there. // Instead, we copy the file to the tmp directory and scan it from there.
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp'; $tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
@ -42,6 +43,8 @@ abstract class CloudStorage extends SongStorage
public function copyToLocal(Song $song): string public function copyToLocal(Song $song): string
{ {
self::assertSupported();
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp'; $tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
File::ensureDirectoryExists($tmpDir); File::ensureDirectoryExists($tmpDir);

View file

@ -27,6 +27,8 @@ final class DropboxStorage extends CloudStorage
public function storeUploadedFile(UploadedFile $file, User $uploader): Song public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{ {
self::assertSupported();
return DB::transaction(function () use ($file, $uploader): Song { return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($file, $uploader); $result = $this->scanUploadedFile($file, $uploader);
$song = $this->scanner->getSong(); $song = $this->scanner->getSong();
@ -71,16 +73,20 @@ final class DropboxStorage extends CloudStorage
public function getSongPresignedUrl(Song $song): string public function getSongPresignedUrl(Song $song): string
{ {
self::assertSupported();
return $this->filesystem->temporaryUrl($song->storage_metadata->getPath()); return $this->filesystem->temporaryUrl($song->storage_metadata->getPath());
} }
public function supported(): bool protected function supported(): bool
{ {
return SongStorageTypes::supported(SongStorageTypes::DROPBOX); return SongStorageTypes::supported(SongStorageTypes::DROPBOX);
} }
public function delete(Song $song, bool $backup = false): void public function delete(Song $song, bool $backup = false): void
{ {
self::assertSupported();
$path = $song->storage_metadata->getPath(); $path = $song->storage_metadata->getPath();
if ($backup) { if ($backup) {

View file

@ -21,11 +21,12 @@ final class LocalStorage extends SongStorage
{ {
public function __construct(private FileScanner $scanner) public function __construct(private FileScanner $scanner)
{ {
parent::__construct();
} }
public function storeUploadedFile(UploadedFile $file, User $uploader): Song public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{ {
self::assertSupported();
$uploadDirectory = $this->getUploadDirectory($uploader); $uploadDirectory = $this->getUploadDirectory($uploader);
$targetFileName = $this->getTargetFileName($file, $uploader); $targetFileName = $this->getTargetFileName($file, $uploader);

View file

@ -20,6 +20,8 @@ class S3CompatibleStorage extends CloudStorage
public function storeUploadedFile(UploadedFile $file, User $uploader): Song public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{ {
self::assertSupported();
return DB::transaction(function () use ($file, $uploader): Song { return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($file, $uploader); $result = $this->scanUploadedFile($file, $uploader);
$song = $this->scanner->getSong(); $song = $this->scanner->getSong();
@ -40,11 +42,15 @@ class S3CompatibleStorage extends CloudStorage
public function getSongPresignedUrl(Song $song): string public function getSongPresignedUrl(Song $song): string
{ {
self::assertSupported();
return Storage::disk('s3')->temporaryUrl($song->storage_metadata->getPath(), now()->addHour()); return Storage::disk('s3')->temporaryUrl($song->storage_metadata->getPath(), now()->addHour());
} }
public function delete(Song $song, bool $backup = false): void public function delete(Song $song, bool $backup = false): void
{ {
self::assertSupported();
$disk = Storage::disk('s3'); $disk = Storage::disk('s3');
$path = $song->storage_metadata->getPath(); $path = $song->storage_metadata->getPath();
@ -61,7 +67,7 @@ class S3CompatibleStorage extends CloudStorage
Storage::disk('s3')->delete('test.txt'); Storage::disk('s3')->delete('test.txt');
} }
public function supported(): bool protected function supported(): bool
{ {
return SongStorageTypes::supported(SongStorageTypes::S3); return SongStorageTypes::supported(SongStorageTypes::S3);
} }

View file

@ -29,6 +29,8 @@ final class S3LambdaStorage extends S3CompatibleStorage
public function storeUploadedFile(UploadedFile $file, User $uploader): Song public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{ {
self::assertSupported();
throw new MethodNotImplementedException('Lambda storage does not support uploading.'); throw new MethodNotImplementedException('Lambda storage does not support uploading.');
} }
@ -85,7 +87,7 @@ final class S3LambdaStorage extends S3CompatibleStorage
$song->delete(); $song->delete();
} }
public function supported(): bool protected function supported(): bool
{ {
return SongStorageTypes::supported(SongStorageTypes::S3_LAMBDA); return SongStorageTypes::supported(SongStorageTypes::S3_LAMBDA);
} }

View file

@ -9,17 +9,17 @@ use Illuminate\Http\UploadedFile;
abstract class SongStorage abstract class SongStorage
{ {
public function __construct() abstract public function storeUploadedFile(UploadedFile $file, User $uploader): Song;
abstract public function delete(Song $song, bool $backup = false): void;
abstract protected function supported(): bool;
protected function assertSupported(): void
{ {
throw_unless( throw_unless(
$this->supported(), $this->supported(),
new KoelPlusRequiredException('The storage driver is only supported in Koel Plus.') new KoelPlusRequiredException('The storage driver is only supported in Koel Plus.')
); );
} }
abstract public function storeUploadedFile(UploadedFile $file, User $uploader): Song;
abstract public function delete(Song $song, bool $backup = false): void;
abstract protected function supported(): bool;
} }

View file

@ -6,6 +6,7 @@ use App\Http\Integrations\YouTube\Requests\SearchVideosRequest;
use App\Http\Integrations\YouTube\YouTubeConnector; use App\Http\Integrations\YouTube\YouTubeConnector;
use App\Models\Song; use App\Models\Song;
use Illuminate\Cache\Repository as Cache; use Illuminate\Cache\Repository as Cache;
use Throwable;
class YouTubeService class YouTubeService
{ {
@ -27,10 +28,14 @@ class YouTubeService
$request = new SearchVideosRequest($song, $pageToken); $request = new SearchVideosRequest($song, $pageToken);
$hash = md5(serialize($request->query()->all())); $hash = md5(serialize($request->query()->all()));
return $this->cache->remember( try {
"youtube.$hash", return $this->cache->remember(
now()->addWeek(), "youtube.$hash",
fn () => $this->connector->send($request)->object() now()->addWeek(),
); fn () => $this->connector->send($request)->object()
);
} catch (Throwable) {
return null;
}
} }
} }

View file

@ -18,7 +18,7 @@ final class PlaylistCollaborator implements Arrayable
public static function fromUser(User $user): self public static function fromUser(User $user): self
{ {
return new self($user->id, $user->name, gravatar($user->email)); return new self($user->id, $user->name, $user->avatar);
} }
/** @return array<mixed> */ /** @return array<mixed> */

View file

@ -74,9 +74,11 @@
"lint-staged": "^10.3.0", "lint-staged": "^10.3.0",
"lucide-vue-next": "^0.363.0", "lucide-vue-next": "^0.363.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-mixins": "^10.0.0",
"postcss-nested": "^6.0.1", "postcss-nested": "^6.0.1",
"qrcode": "^1", "qrcode": "^1",
"start-server-and-test": "^2.0.3", "start-server-and-test": "^2.0.3",
"tailwindcss": "^3.4.3",
"typescript": "^4.8.4", "typescript": "^4.8.4",
"vite": "^5.1.6", "vite": "^5.1.6",
"vitepress": "^1.0.0-rc.45", "vitepress": "^1.0.0-rc.45",

View file

@ -1,5 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: [
require('tailwindcss'),
require('postcss-mixins'),
require('postcss-nested'), require('postcss-nested'),
require('autoprefixer') require('autoprefixer')
] ]

View file

@ -1,203 +1,41 @@
@import './vendor/reset.pcss';
@import './partials/vars.pcss'; @import './partials/vars.pcss';
@import './partials/hack.pcss'; @import './partials/hack.pcss';
@import './partials/mixins.pcss';
@import '@modules/nouislider/distribute/nouislider.min.css'; @import '@modules/nouislider/distribute/nouislider.min.css';
@import './vendor/plyr.pcss'; @import './vendor/plyr.pcss';
@import './vendor/nprogress.pcss'; @import './vendor/nprogress.pcss';
@import './partials/skeleton.pcss'; @import './partials/skeleton.pcss';
@import './partials/tooltip.pcss'; @import './partials/tooltip.pcss';
@import './partials/context-menu.pcss';
@import './partials/shared.pcss'; @import './partials/shared.pcss';
.vertical-center { @tailwind base;
display: flex; @tailwind components;
align-items: center; @tailwind utilities;
justify-content: center;
}
.artist-album-wrapper { @layer utilities {
display: grid !important; .fade-top {
gap: 16px; -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
}
&.as-list { .fade-bottom {
gap: 0.7em 1em; -webkit-mask-image: linear-gradient(to top, transparent, black var(--fade-size));
align-content: start; mask-image: linear-gradient(to top, transparent, black var(--fade-size));
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); }
@media only screen and (max-width: 667px) { .fade-top.fade-bottom {
display: block; -webkit-mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
-webkit-mask-size: 100% 51%;
-webkit-mask-repeat: no-repeat;
>*+* { mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
margin-top: .7rem; linear-gradient(to top, transparent, black var(--fade-size)) bottom;
} mask-size: 100% 51%;
} mask-repeat: no-repeat;
} }
} }
.artist-album-info-wrapper {
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.info-wrapper {
color: var(--color-text-secondary);
position: absolute;
top: 0;
left: 0;
background: var(--color-bg-primary);
width: 100%;
height: 100%;
z-index: 2;
.inner {
overflow: auto;
height: 100%;
padding: 24px 24px 48px;
@media only screen and (max-width: 768px) {
padding: 16px;
}
}
.close-modal {
display: none;
}
&:hover {
.close-modal {
display: block;
}
}
}
}
.artist-album-info {
color: var(--color-text-secondary);
h1 {
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-thin);
line-height: 2.8rem;
margin-bottom: 16px;
&.name {
font-size: 2rem;
margin-bottom: 2rem;
}
span {
flex: 1;
margin-right: 12px;
}
a {
font-size: 14px;
&:hover {
color: var(--color-highlight);
}
}
}
.bio,
.wiki {
margin: 16px 0;
}
.more {
margin-top: .75rem;
border-radius: .23rem;
background: var(--color-blue);
color: var(--color-text-primary);
padding: .3rem .6rem;
display: inline-block;
text-transform: uppercase;
font-size: .8rem;
}
.cover,
.cool-guys-posing {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
overflow: hidden;
}
footer {
margin-top: 24px;
font-size: .9rem;
text-align: right;
a {
color: var(--color-text-primary);
font-weight: var(--font-weight-normal);
&:hover {
color: var(--color-text-secondary);
}
}
}
&.full {
.cover {
width: 300px;
max-width: 100%;
float: left;
margin: 0 16px 16px 0;
}
.bio,
.wiki {
margin-top: 0;
}
h1.name {
font-size: 2.4rem;
a.shuffle {
display: none;
}
}
}
}
.inset-when-pressed {
&:not([disabled]):active {
box-shadow: inset 0 10px 10px -10px rgba(0, 0, 0, .6);
}
}
.context-menu {
padding: .4rem 0;
width: max-content;
min-width: 144px;
background-color: var(--color-bg-context-menu);
position: fixed;
border-radius: 4px;
z-index: 1001;
filter: drop-shadow(0 5px 15px rgba(0, 0, 0, .5));
:deep(.arrow) {
background-color: var(--color-bg-context-menu);
position: absolute;
width: 8px;
height: 8px;
transform: rotate(45deg);
}
}
.themed-background, body, html {
background-color: var(--color-bg-primary);
background-image: var(--bg-image);
background-attachment: var(--bg-attachment);
background-size: var(--bg-size);
background-position: var(--bg-position);
}

View file

@ -0,0 +1,26 @@
.context-menu {
@apply py-1 px-0 w-max min-w-[144px] bg-k-bg-context-menu fixed rounded-md z-[1001] shadow-md;
.arrow {
@apply bg-k-bg-context-menu absolute w-[8px] aspect-square rotate-45;
}
li {
@apply relative px-4 py-1.5 whitespace-nowrap hover:bg-k-highlight hover:text-k-text-primary;
&.separator {
@apply pointer-events-none p-0 border-b border-b-white/10;
}
&.has-sub {
@apply pr-[30px] bg-no-repeat;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 30 30' shape-rendering='geometricPrecision' text-rendering='geometricPrecision'%3E%3Cpolygon points='-2.303673 -19.980561 12.696327 5.999439 -17.303673 5.999439 -2.303673 -19.980561' transform='matrix(0 1-1 0 5.999439 17.303673)' fill='%23fff' stroke-width='0'/%3E%3C/svg%3E");
background-position: right 8px center;
background-size: 10px;
}
}
.submenu {
@apply absolute top-0 left-full hidden;
}
}

View file

@ -6,9 +6,7 @@
* Make elements draggable in old WebKit * Make elements draggable in old WebKit
*/ */
[draggable] { [draggable] {
user-select: none; @apply select-none;
-khtml-user-drag: element;
-webkit-user-drag: element;
} }
/** /**
@ -16,44 +14,38 @@
*/ */
html.non-mac { html.non-mac {
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; @apply w-[10px] h-[10px];
height: 10px;
} }
::-webkit-scrollbar-button { ::-webkit-scrollbar-button {
width: 0px; @apply w-0 h-0;
height: 0px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-bg-primary); @apply bg-k-bg-primary border border-white/20 rounded-[50px];
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 50px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #303030; @apply bg-[#303030];
} }
::-webkit-scrollbar-thumb:active { ::-webkit-scrollbar-thumb:active {
background: var(--color-bg-primary); @apply bg-k-bg-primary;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--color-bg-primary); @apply bg-k-bg-primary border-0 rounded-[50px];
border: 0px none var(--color-text-primary);
border-radius: 50px;
} }
::-webkit-scrollbar-track:hover { ::-webkit-scrollbar-track:hover {
background: var(--color-bg-primary); @apply bg-k-bg-primary;
} }
::-webkit-scrollbar-track:active { ::-webkit-scrollbar-track:active {
background: #333333; @apply bg-[#333];
} }
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: transparent; @apply bg-transparent;
} }
} }

View file

@ -0,0 +1,7 @@
@define-mixin themed-background {
background-color: var(--color-bg-primary);
background-image: var(--bg-image);
background-attachment: var(--bg-attachment);
background-size: var(--bg-size);
background-position: var(--bg-position);
}

View file

@ -1,416 +1,54 @@
*, @tailwind base;
*::before,
*::after {
box-sizing: border-box;
outline: none;
}
h1, h2, h3, h4, h5, h6, blockquote { @layer base {
text-wrap: balance; h1, h2, h3, h4, h5, h6, blockquote {
} @apply text-balance;
*::marker {
display: none !important;
}
:root {
color-scheme: dark;
}
::placeholder {
color: rgba(0, 0, 0, .5);
}
body,
html {
color: var(--color-text-primary);
font-family: var(--font-family);
font-size: 13px;
line-height: 1.5rem;
font-weight: var(--font-weight-light);
overflow: hidden;
}
input {
height: 32px;
}
input,
select,
button,
textarea,
.btn {
appearance: none;
border: 0;
outline: 0;
font-family: var(--font-family);
font-size: 1rem;
font-weight: var(--font-weight-light);
padding: .5rem .6rem;
border-radius: var(--border-radius-input);
margin: 0;
background: var(--color-bg-input);
color: var(--color-input);
&:required,
&:invalid {
box-shadow: none;
} }
&[type="search"] { *::marker {
border-radius: 12px; @apply hidden !important;
height: 24px;
padding: 0 .5rem;
} }
&[type="text"] { :root {
display: block; color-scheme: dark;
} }
&[disabled], &[readonly] { ::placeholder {
background: rgba(255, 255, 255, .7); @apply text-black/5;
cursor: not-allowed;
} }
&:focus { body, html {
outline: none !important; @mixin themed-background;
font-family: var(--font-family);
font-size: 13px;
@apply text-k-text-primary font-light leading-6 overflow-hidden;
} }
&::-moz-focus-inner { input, select, button, textarea, .btn {
border: 0 !important; font-family: var(--font-family);
}
}
button, @apply appearance-none border-0 outline-0 text-base font-light;
[role=button] {
cursor: pointer;
background: transparent;
padding: 0;
border: 0;
color: currentColor;
}
select { &:required, &:invalid {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAA4UlEQVRYhe3WMQ6CMABA0Y9xcHDVlBM5OXsC4xXcvImzg/EQTNykHZx0JcHFxkKQtpQGhv5OtCG8pElLVlMzdYupAZAQvxJClxC6WSCW5kMu8jNwAVYRvlUBV6nkqb2QmSdmLvI3sI4AMNtIJZ/mRHs77pEBRRvQhTgCj0iAEth3LTQQUskKOESAlMBOKvmyIiJBegGdiJEhVsBfxEgQJ0AvIhDiDLAiBkK8AE4IT4g3wBnhCBkE8EJYIIMB3ogW5PadKkIAANQBQwixDXlfj8YtOlWz+KlJCF1C6BJCNwvEB8RnttABpb3tAAAAAElFTkSuQmCC); @apply shadow-none;
background-size: 12px; }
background-position: calc(100% - 8px) 50%;
padding-right: 26px;
background-repeat: no-repeat;
}
.hidden { &::-moz-focus-inner {
display: none !important; @apply border-0 !important;
}
input[type="checkbox"] {
border-radius: 2px;
cursor: pointer;
height: 15px;
margin: -4px 4px 0 0;
padding: 0 !important;
vertical-align: middle;
width: 15px;
background: var(--color-text-primary);
}
a {
text-decoration: none;
cursor: pointer;
&:link,
&:visited {
color: var(--color-text-secondary);
}
&:hover,
&:focus {
color: var(--color-accent);
}
}
.control {
cursor: pointer;
color: var(--color-text-secondary);
&:hover {
color: var(--color-text-primary);
}
}
p {
line-height: 1.5rem;
}
em {
font-style: italic;
}
.help {
opacity: .7;
font-size: .9rem;
line-height: 1.3rem;
}
label {
font-size: 1.1rem;
display: block;
cursor: pointer;
&.small {
font-size: 1rem;
}
}
.pointer-events-none {
pointer-events: none;
}
.tabs {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
[role=tablist] {
overflow: auto;
border-bottom: 2px solid rgba(255, 255, 255, .1);
padding: 0 1.25rem;
display: flex;
gap: 0.3rem;
min-height: 36px;
[role=tab] {
position: relative;
padding: .7rem 1.3rem;
border-radius: 4px 4px 0 0;
opacity: .7;
background: rgba(255, 255, 255, .05);
text-transform: uppercase;
color: var(--color-text-secondary);
&:hover {
transition: .3s;
background: rgba(255, 255, 255, .1);
}
&[aria-selected=true] {
transition: none;
color: var(--color-text-primary);
background: rgba(255, 255, 255, .1);
opacity: 1;
}
} }
} }
.panes { a {
padding: 1.25rem; @apply no-underline text-k-text-primary cursor-pointer hover:text-k-accent focus:text-k-accent;
}
}
.form-row + .form-row { &:link, &:visited {
margin-top: 1.125rem; @apply text-k-accent;
position: relative;
}
.form-row.cols {
display: flex;
place-content: space-between;
gap: 1rem;
> * {
flex: 1;
}
}
.form-row input:not([type="checkbox"]),
.form-row select {
margin-top: .7rem;
display: block;
height: 32px;
}
.font-size- {
&0 {
font-size: 0;
}
&1 {
font-size: 1rem;
}
&1\.5 {
font-size: 1.5rem;
}
&2 {
font-size: 2rem;
}
}
.text- {
&primary {
color: var(--color-text-primary) !important;
}
&secondary {
color: var(--color-text-secondary) !important;
}
&highlight {
color: var(--color-highlight) !important;
}
&maroon {
color: var(--color-maroon) !important;
}
&red {
color: var(--color-red) !important;
}
&blue {
color: var(--color-blue) !important;
}
&green {
color: var(--color-green) !important;
}
&uppercase {
text-transform: uppercase !important;
}
&thin {
font-weight: var(--font-weight-thin) !important;
}
&normal {
font-weight: var(--font-weight-normal) !important;
}
&light {
font-weight: var(--font-weight-light) !important;
}
&bold {
font-weight: var(--font-weight-bold) !important;
}
}
.bg- {
&primary {
background-color: var(--color-bg-primary) !important;
}
&secondary {
background-color: var(--color-bg-secondary) !important;
}
}
.d- {
&block {
display: block;
}
&inline {
display: block;
}
&inline-block {
display: inline-block;
}
&flex {
display: flex;
}
&inline-flex {
display: inline-block;
}
&grid {
display: grid;
}
&inline-grid {
display: inline-grid;
}
}
.context-menu,
.submenu,
menu {
position: fixed;
@keyframes subMenuHoverHelp {
0% {
height: 500%;
}
99% {
height: 500%;
}
100% {
height: 0;
} }
} }
li { :root {
list-style: none; --fade-size: 3rem;
position: relative;
padding: 4px 12px;
cursor: default;
white-space: nowrap;
&:hover {
background: var(--color-highlight);
color: var(--color-text-primary);
}
&.separator {
pointer-events: none;
padding: 0;
border-bottom: 1px solid rgba(255, 255, 255, .1);
}
&.has-sub {
padding-right: 30px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 30 30' shape-rendering='geometricPrecision' text-rendering='geometricPrecision'%3E%3Cpolygon points='-2.303673 -19.980561 12.696327 5.999439 -17.303673 5.999439 -2.303673 -19.980561' transform='matrix(0 1-1 0 5.999439 17.303673)' fill='%23fff' stroke-width='0'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px;
}
}
.submenu {
position: absolute;
display: none;
left: 100%;
top: 0;
} }
} }
:root {
--fade-size: 3rem;
}
.fade-top {
-webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
}
.fade-bottom {
-webkit-mask-image: linear-gradient(to top, transparent, black var(--fade-size));
mask-image: linear-gradient(to top, transparent, black var(--fade-size));
}
.fade-top.fade-bottom {
-webkit-mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
-webkit-mask-size: 100% 51%;
-webkit-mask-repeat: no-repeat;
mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
mask-size: 100% 51%;
mask-repeat: no-repeat;
}

View file

@ -1,15 +1,15 @@
.skeleton { .skeleton {
.pulse, &.pulse { .pulse, &.pulse {
@apply bg-white/5;
animation: skeleton-pulse 2s infinite; animation: skeleton-pulse 2s infinite;
background-color: rgba(255, 255, 255, .05);
} }
@keyframes skeleton-pulse { @keyframes skeleton-pulse {
0%, 100% { 0%, 100% {
opacity: 0; @apply opacity-0;
} }
50% { 50% {
opacity: 1; @apply opacity-100;
} }
} }
} }

View file

@ -1,36 +1,12 @@
.tooltip { .tooltip {
opacity: 0; @apply opacity-0 text-white/80 w-max absolute top-0 left-0 bg-black px-3 py-2 rounded-md pointer-events-none
color: rgba(255, 255, 255, .8); drop-shadow z-[9999] no-hover:hidden;
width: max-content;
position: absolute;
top: 0;
left: 0;
background: #111;
padding: 5px 12px;
border-radius: 4px;
pointer-events: none;
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, .3));
z-index: 9999;
&.show { &.show {
opacity: 1; @apply opacity-100 transition-opacity duration-500 ease-in-out;
transition: opacity .5s ease-in-out;
transition-delay: .3s;
} }
&-arrow { &-arrow {
position: absolute; @apply absolute bg-black w-[8px] aspect-square rotate-45;
background: #111;
width: 8px;
height: 8px;
transform: rotate(45deg);
}
@media (hover: none) {
display: none !important;
}
@media screen and (max-width: 768px) {
display: none !important;
} }
} }

View file

@ -2,13 +2,13 @@
--color-text-primary: #fff; --color-text-primary: #fff;
--color-text-secondary: rgba(255, 255, 255, .7); --color-text-secondary: rgba(255, 255, 255, .7);
--color-bg-primary: #181818; --color-bg-primary: #181818;
--color-bg-secondary: rgba(255, 255, 255, .025); --color-bg-secondary: #1d1d1d;
--color-border: var(--color-bg-secondary); --color-border: var(--color-bg-secondary);
--color-highlight: #ff7d2e; --color-highlight: #ff7d2e;
--color-accent: var(--color-highlight); --color-accent: var(--color-highlight);
--color-bg-context-menu: var(--color-bg-primary); --color-bg-context-menu: var(--color-bg-primary);
--color-input: #333; --color-text-input: #333;
--color-bg-input: #fff; --color-bg-input: #fff;
--bg-image: none; --bg-image: none;
@ -18,23 +18,15 @@
--font-family: system, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; --font-family: system, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--font-weight-thin: 100;
--font-weight-light: 300;
--font-weight-normal: 500;
--font-weight-bold: 700;
--header-height: auto; --header-height: auto;
--footer-height: 84px; --footer-height: 84px;
--extra-drawer-width: 320px; --extra-drawer-width: 25rem;
--sidebar-width: 256px; --sidebar-width: 20rem;
--color-black: #181818; --color-love: #bf2043;
--color-maroon: #bf2043; --color-success: #56a052;
--color-green: #56a052; --color-primary: #0191f7;
--color-blue: #0191f7; --color-danger: #c34848;
--color-red: #c34848;
--border-radius-input: .3rem;
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
--header-height: 56px; --header-height: 56px;
@ -42,5 +34,3 @@
--extra-drawer-width: 100%; --extra-drawer-width: 100%;
} }
} }
$plyr-blue: var(--color-highlight);

View file

@ -1,4 +1,15 @@
@import './vendor/reset.pcss'; @import './vendor/reset.pcss';
@import '@modules/nouislider/distribute/nouislider.min.css'; @import '@modules/nouislider/distribute/nouislider.min.css';
@import './partials/vars.pcss'; @import './partials/vars.pcss';
@import './partials/mixins.pcss';
@import './partials/shared.pcss'; @import './partials/shared.pcss';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body, html {
@apply h-screen relative;
}
}

View file

@ -1,21 +0,0 @@
.alertify {
font-family: var(--font-family);
font-weight: var(--font-weight-light);
background-color: rgba(0, 0, 0, .7);
z-index: 9999;
color: rgba(0,0,0,.87);
.dialog > div {
border-radius: 3px;
}
}
.alertify-logs {
font-family: var(--font-family);
font-weight: var(--font-weight-thin);
z-index: 9999;
.show {
border-radius: 3px;
}
}

View file

@ -4,44 +4,22 @@
* > The included CSS file is pretty minimal... in fact, feel free to scrap it and make your own! * > The included CSS file is pretty minimal... in fact, feel free to scrap it and make your own!
*/ */
#nprogress { #nprogress {
pointer-events: none; @apply pointer-events-none;
.bar { .bar {
display: none; @apply hidden;
} }
/* Fancy blur effect */ /* Fancy blur effect */
.peg { .peg {
display: none; @apply hidden;
} }
.spinner { .spinner {
display: block; @apply block fixed z-[9999] top-[15px] right-[13px];
position: fixed;
z-index: 9999;
top: 15px;
right: 13px;
} }
.spinner-icon { .spinner-icon {
width: 18px; @apply w-[18px] aspect-square border-2 border-transparent border-t-k-highlight border-l-k-highlight rounded-full animate-spin;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: var(--color-highlight);
border-left-color: var(--color-highlight);
border-radius: 50%;
animation: nprogress-spinner 400ms linear infinite;
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
} }
} }

View file

@ -5,7 +5,13 @@
<GlobalEventListeners /> <GlobalEventListeners />
<OfflineNotification v-if="!online" /> <OfflineNotification v-if="!online" />
<div v-if="layout === 'main' && initialized" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop"> <main
v-if="layout === 'main' && initialized"
class="absolute md:relative top-0 h-full md:h-screen pt-k-header-height md:pt-0 w-full md:w-auto flex flex-col justify-end"
@dragend="onDragEnd"
@dragover="onDragOver"
@drop="onDrop"
>
<Hotkeys /> <Hotkeys />
<MainWrapper /> <MainWrapper />
<AppFooter /> <AppFooter />
@ -17,7 +23,7 @@
<PlaylistFolderContextMenu /> <PlaylistFolderContextMenu />
<CreateNewPlaylistContextMenu /> <CreateNewPlaylistContextMenu />
<DropZone v-show="showDropZone" @close="showDropZone = false" /> <DropZone v-show="showDropZone" @close="showDropZone = false" />
</div> </main>
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" /> <LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
@ -31,10 +37,10 @@ import { useOnline } from '@vueuse/core'
import { commonStore, preferenceStore as preferences, queueStore } from '@/stores' import { commonStore, preferenceStore as preferences, queueStore } from '@/stores'
import { authService, socketListener, socketService, uploadService } from '@/services' import { authService, socketListener, socketService, uploadService } from '@/services'
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols' import { CurrentSongKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols'
import { useRouter } from '@/composables' import { useRouter, useOverlay } from '@/composables'
import DialogBox from '@/components/ui/DialogBox.vue' import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue' import MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
import Overlay from '@/components/ui/Overlay.vue' import Overlay from '@/components/ui/Overlay.vue'
import OfflineNotification from '@/components/ui/OfflineNotification.vue' import OfflineNotification from '@/components/ui/OfflineNotification.vue'
@ -53,7 +59,7 @@ const ArtistContextMenu = defineAsyncComponent(() => import('@/components/artist
const PlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistContextMenu.vue')) const PlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistContextMenu.vue'))
const PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistFolderContextMenu.vue')) const PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistFolderContextMenu.vue'))
const SongContextMenu = defineAsyncComponent(() => import('@/components/song/SongContextMenu.vue')) const SongContextMenu = defineAsyncComponent(() => import('@/components/song/SongContextMenu.vue'))
const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreateNewPlaylistContextMenu.vue')) const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistContextMenu.vue'))
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue')) const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue')) const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue')) const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue'))
@ -121,7 +127,7 @@ onMounted(async () => {
const initialized = ref(false) const initialized = ref(false)
const init = async () => { const init = async () => {
overlay.value!.show({ message: 'Just a little patience…' }) useOverlay(overlay).showOverlay({ message: 'Just a little patience…' })
try { try {
await commonStore.init() await commonStore.init()
@ -141,7 +147,7 @@ const init = async () => {
layout.value = 'auth' layout.value = 'auth'
throw err throw err
} finally { } finally {
overlay.value!.hide() useOverlay(overlay).hideOverlay()
} }
} }
@ -162,50 +168,11 @@ provide(CurrentSongKey, currentSong)
<style lang="postcss"> <style lang="postcss">
#dragGhost { #dragGhost {
display: inline-block; @apply inline-block py-2 px-3 rounded-md text-base font-sans fixed top-0 left-0 z-[-1] bg-k-success
background: var(--color-green); text-k-text-primary no-hover:hidden;
padding: .8rem;
border-radius: .3rem;
color: var(--color-text-primary);
font-family: var(--font-family);
font-size: 1rem;
font-weight: var(--font-weight-light);
position: fixed;
top: 0;
left: 0;
z-index: -1;
@media (hover: none) {
display: none;
}
} }
#copyArea { #copyArea {
position: absolute; @apply absolute -left-full bottom-px w-px h-px no-hover:hidden;
left: -9999px;
width: 1px;
height: 1px;
bottom: 1px;
@media (hover: none) {
display: none;
}
}
#main {
display: flex;
height: 100vh;
flex-direction: column;
justify-content: flex-end;
}
#main {
@media screen and (max-width: 768px) {
position: absolute;
height: 100%;
width: 100%;
top: 0;
padding-top: var(--header-height);
}
} }
</style> </style>

View file

@ -1,7 +1,7 @@
import { Ref, ref } from 'vue' import { Ref, ref } from 'vue'
import { noop } from '@/utils' import { noop } from '@/utils'
import MessageToaster from '@/components/ui/MessageToaster.vue' import MessageToaster from '@/components/ui/message-toaster/MessageToaster.vue'
import DialogBox from '@/components/ui/DialogBox.vue' import DialogBox from '@/components/ui/DialogBox.vue'
import Overlay from '@/components/ui/Overlay.vue' import Overlay from '@/components/ui/Overlay.vue'

View file

@ -9,24 +9,18 @@
@dragstart="onDragStart" @dragstart="onDragStart"
> >
<template #name> <template #name>
<a :href="`#/album/${album.id}`" class="text-normal" data-testid="name">{{ album.name }}</a> <a :href="`#/album/${album.id}`" class="font-medium" data-testid="name">{{ album.name }}</a>
<a v-if="isStandardArtist" :href="`#/artist/${album.artist_id}`">{{ album.artist_name }}</a> <a v-if="isStandardArtist" :href="`#/artist/${album.artist_id}`">{{ album.artist_name }}</a>
<span v-else class="text-secondary">{{ album.artist_name }}</span> <span v-else class="text-k-text-secondary">{{ album.artist_name }}</span>
</template> </template>
<template #meta> <template #meta>
<a <a :title="`Shuffle all songs in the album ${album.name}`" role="button" @click.prevent="shuffle">
:title="`Shuffle all songs in the album ${album.name}`"
class="shuffle-album"
role="button"
@click.prevent="shuffle"
>
Shuffle Shuffle
</a> </a>
<a <a
v-if="allowDownload" v-if="allowDownload"
:title="`Download all songs in the album ${album.name}`" :title="`Download all songs in the album ${album.name}`"
class="download-album"
role="button" role="button"
@click.prevent="download" @click.prevent="download"
> >

View file

@ -1,70 +1,59 @@
<template> <template>
<article :class="mode" class="album-info artist-album-info" data-testid="album-info"> <AlbumArtistInfo :mode="mode" data-testid="album-info">
<h1 v-if="mode === 'aside'" class="name"> <template #header>{{ album.name }}</template>
<span>{{ album.name }}</span>
<button :title="`Play all songs in ${album.name}`" class="control" type="button" @click.prevent="play">
<Icon :icon="faCirclePlay" size="xl" />
</button>
</h1>
<main> <template #art>
<AlbumThumbnail v-if="mode === 'aside'" :entity="album" /> <AlbumThumbnail :entity="album" />
</template>
<template v-if="info"> <template v-if="info">
<div v-if="info.wiki?.summary" class="wiki"> <template v-if="info.wiki">
<div v-if="showSummary" class="summary" data-testid="summary" v-html="info.wiki.summary" /> <ExpandableContentBlock v-if="mode === 'aside'">
<div v-if="showFull" class="full" data-testid="full" v-html="info.wiki.full" /> <div v-html="info.wiki.full" />
</ExpandableContentBlock>
<button v-if="showSummary" class="more" @click.prevent="showingFullWiki = true"> <div v-else v-html="info.wiki.full" />
Full Wiki
</button>
</div>
<TrackList v-if="info.tracks?.length" :album="album" :tracks="info.tracks" data-testid="album-info-tracks" />
<footer>
Data &copy;
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
</footer>
</template> </template>
</main>
</article> <TrackList
v-if="info.tracks?.length"
:album="album"
:tracks="info.tracks"
data-testid="album-info-tracks"
class="mt-8"
/>
</template>
<template v-if="info" #footer>
Data &copy;
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
</template>
</AlbumArtistInfo>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons' import { defineAsyncComponent, ref, toRefs, watch } from 'vue'
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue' import { mediaInfoService } from '@/services'
import { songStore } from '@/stores' import { useThirdPartyServices } from '@/composables'
import { mediaInfoService, playbackService } from '@/services'
import { useRouter, useThirdPartyServices } from '@/composables'
import AlbumThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue' import AlbumThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
import AlbumArtistInfo from '@/components/ui/album-artist/AlbumOrArtistInfo.vue'
import ExpandableContentBlock from '@/components/ui/album-artist/ExpandableContentBlock.vue'
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue')) const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' }) const props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const { album, mode } = toRefs(props) const { album, mode } = toRefs(props)
const { go } = useRouter()
const { useLastfm, useSpotify } = useThirdPartyServices() const { useLastfm, useSpotify } = useThirdPartyServices()
const info = ref<AlbumInfo | null>(null) const info = ref<AlbumInfo | null>(null)
const showingFullWiki = ref(false)
watch(album, async () => { watch(album, async () => {
showingFullWiki.value = false
info.value = null info.value = null
if (useLastfm.value || useSpotify.value) { if (useLastfm.value || useSpotify.value) {
info.value = await mediaInfoService.fetchForAlbum(album.value) info.value = await mediaInfoService.fetchForAlbum(album.value)
} }
}, { immediate: true }) }, { immediate: true })
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
const showFull = computed(() => !showSummary.value)
const play = async () => {
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
go('queue')
}
</script> </script>

View file

@ -1,9 +1,14 @@
<template> <template>
<article class="track-listing"> <article>
<h3>Track Listing</h3> <h3 class="text-2xl mb-3">Track Listing</h3>
<ul class="tracks"> <ul>
<li v-for="(track, index) in tracks" :key="index" data-testid="album-track-item"> <li
v-for="(track, index) in tracks"
:key="index"
data-testid="album-track-item"
class="flex p-2 before:w-7 before:opacity-50"
>
<TrackListItem :album="album" :track="track" /> <TrackListItem :album="album" :track="track" />
</li> </li>
</ul> </ul>
@ -29,30 +34,19 @@ onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
article { ul {
h3 { counter-reset: trackCounter;
font-size: 1.4rem; }
margin-bottom: 1rem;
li {
counter-increment: trackCounter;
&::before {
content: counter(trackCounter);
} }
ul { &:nth-child(even) {
counter-reset: trackCounter; background: rgba(255, 255, 255, 0.05);
}
li {
counter-increment: trackCounter;
display: flex;
padding: 8px;
&::before {
content: counter(trackCounter);
flex: 0 0 24px;
opacity: .5;
}
&:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
} }
} }
</style> </style>

View file

@ -1,14 +1,14 @@
<template> <template>
<div <div
class="track-list-item" class="track-list-item flex flex-1 gap-1"
:class="{ active, available: matchedSong }" :class="{ active, available: matchedSong }"
:title="tooltip" :title="tooltip"
tabindex="0" tabindex="0"
@click="play" @click="play"
> >
<span class="title">{{ track.title }}</span> <span class="flex-1">{{ track.title }}</span>
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl" /> <AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl" />
<span class="length">{{ fmtLength }}</span> <span class="w-14 text-right opacity-50">{{ fmtLength }}</span>
</div> </div>
</template> </template>
@ -49,32 +49,17 @@ const play = () => {
<style lang="postcss" scoped> <style lang="postcss" scoped>
.track-list-item { .track-list-item {
display: flex;
flex: 1;
gap: 4px;
&:focus, &.active { &:focus, &.active {
span.title { span.title {
color: var(--color-highlight); @apply text-k-highlight;
} }
} }
.title {
flex: 1;
}
.length {
flex: 0 0 44px;
text-align: right;
opacity: .5;
}
&.available { &.available {
color: var(--color-text-primary); @apply cursor-pointer text-k-text-primary;
cursor: pointer;
&:hover { &:hover {
color: var(--color-highlight); @apply text-k-highlight;
} }
} }
} }

View file

@ -1,10 +1,10 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<article data-v-f01bdc56="" class="item full" draggable="true" tabindex="0" title="IV by Led Zeppelin"><br data-v-f01bdc56="" data-testid="thumbnail" entity="[object Object]"> <article data-v-f01bdc56="" class="item tw-relative tw-flex md:tw-max-w-full tw-max-w-[256px] tw-border tw-p-5 tw-rounded-lg tw-flex-col tw-gap-5 tw-transition tw-border-color tw-duration-200 full" draggable="true" tabindex="0" title="IV by Led Zeppelin"><br data-v-f01bdc56="" data-testid="thumbnail" entity="[object Object]">
<footer data-v-f01bdc56=""> <footer data-v-f01bdc56="" class="tw-flex tw-flex-1 tw-flex-col tw-gap-1.5 tw-overflow-hidden">
<div data-v-f01bdc56="" class="name"><a href="#/album/42" class="text-normal" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div> <div data-v-f01bdc56="" class="name tw-flex tw-flex-col tw-gap-2 tw-whitespace-nowrap"><a href="#/album/42" class="text-normal" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
<p data-v-f01bdc56="" class="meta"><a title="Shuffle all songs in the album IV" class="shuffle-album" role="button"> Shuffle </a><a title="Download all songs in the album IV" class="download-album" role="button"> Download </a></p> <p data-v-f01bdc56="" class="meta tw-text-[0.9rem] tw-flex tw-gap-1.5 tw-opacity-70 hover:tw-opacity-100"><a title="Shuffle all songs in the album IV" class="shuffle-album" role="button"> Shuffle </a><a title="Download all songs in the album IV" class="download-album" role="button"> Download </a></p>
</footer> </footer>
</article> </article>
`; `;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<nav data-v-0408531a="" class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu"> <nav data-v-0408531a="" class="album-menu menu context-menu tw-select-none" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu">
<ul data-v-0408531a=""> <ul data-v-0408531a="">
<li>Play All</li> <li>Play All</li>
<li>Shuffle All</li> <li>Shuffle All</li>

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<div data-v-da281390="" class="track-list-item" title="" tabindex="0"><span data-v-da281390="" class="title">Fahrstuhl to Heaven</span> <div data-v-da281390="" class="track-list-item tw-flex tw-flex-1 tw-gap-1" title="" tabindex="0"><span data-v-da281390="" class="tw-flex-1">Fahrstuhl to Heaven</span>
<!----><span data-v-da281390="" class="length">04:40</span> <!----><span data-v-da281390="" class="tw-w-14 tw-text-right tw-opacity-50">04:40</span>
</div> </div>
`; `;

View file

@ -9,24 +9,13 @@
@dragstart="onDragStart" @dragstart="onDragStart"
> >
<template #name> <template #name>
<a :href="`#/artist/${artist.id}`" class="text-normal" data-testid="name">{{ artist.name }}</a> <a :href="`#/artist/${artist.id}`" class="font-medium" data-testid="name">{{ artist.name }}</a>
</template> </template>
<template #meta> <template #meta>
<a <a :title="`Shuffle all songs by ${artist.name}`" role="button" @click.prevent="shuffle">
:title="`Shuffle all songs by ${artist.name}`"
class="shuffle-artist"
role="button"
@click.prevent="shuffle"
>
Shuffle Shuffle
</a> </a>
<a <a v-if="allowDownload" :title="`Download all songs by ${artist.name}`" role="button" @click.prevent="download">
v-if="allowDownload"
:title="`Download all songs by ${artist.name}`"
class="download-artist"
role="button"
@click.prevent="download"
>
Download Download
</a> </a>
</template> </template>

View file

@ -1,74 +1,47 @@
<template> <template>
<article :class="mode" class="artist-info artist-album-info" data-testid="artist-info"> <AlbumArtistInfo :mode="mode" data-testid="artist-info">
<h1 v-if="mode === 'aside'" class="name"> <template #header>{{ artist.name }}</template>
<span>{{ artist.name }}</span>
<button :title="`Play all songs by ${artist.name}`" class="control" type="button" @click.prevent="play">
<Icon :icon="faCirclePlay" size="xl" />
</button>
</h1>
<main> <template #art>
<ArtistThumbnail v-if="mode === 'aside'" :entity="artist" /> <ArtistThumbnail :entity="artist" />
</template>
<template v-if="info"> <template v-if="info?.bio">
<div v-if="info.bio?.summary" class="bio"> <ExpandableContentBlock v-if="mode === 'aside'">
<div v-if="showSummary" class="summary" data-testid="summary" v-html="info.bio.summary" /> <div v-html="info.bio.full" />
<div v-if="showFull" class="full" data-testid="full" v-html="info.bio.full" /> </ExpandableContentBlock>
<button v-if="showSummary" class="more" @click.prevent="showingFullBio = true"> <div v-else v-html="info.bio.full" />
Full Bio </template>
</button>
</div>
<footer> <template v-if="info" #footer>
Data &copy; Data &copy;
<a :href="info.url" rel="openener" target="_blank">Last.fm</a> <a :href="info.url" rel="openener" target="_blank">Last.fm</a>
</footer> </template>
</template> </AlbumArtistInfo>
</main>
</article>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons' import { ref, toRefs, watch } from 'vue'
import { computed, ref, toRefs, watch } from 'vue' import { mediaInfoService } from '@/services'
import { mediaInfoService, playbackService } from '@/services' import { useThirdPartyServices } from '@/composables'
import { useRouter, useThirdPartyServices } from '@/composables'
import { songStore } from '@/stores'
import ArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue' import ArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
import AlbumArtistInfo from '@/components/ui/album-artist/AlbumOrArtistInfo.vue'
import ExpandableContentBlock from '@/components/ui/album-artist/ExpandableContentBlock.vue'
const props = withDefaults(defineProps<{ artist: Artist, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' }) const props = withDefaults(defineProps<{ artist: Artist, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
const { artist, mode } = toRefs(props) const { artist, mode } = toRefs(props)
const { go } = useRouter()
const { useLastfm, useSpotify } = useThirdPartyServices() const { useLastfm, useSpotify } = useThirdPartyServices()
const info = ref<ArtistInfo | null>(null) const info = ref<ArtistInfo | null>(null)
const showingFullBio = ref(false)
watch(artist, async () => { watch(artist, async () => {
showingFullBio.value = false
info.value = null info.value = null
if (useLastfm.value || useSpotify.value) { if (useLastfm.value || useSpotify.value) {
info.value = await mediaInfoService.fetchForArtist(artist.value) info.value = await mediaInfoService.fetchForArtist(artist.value)
} }
}, { immediate: true }) }, { immediate: true })
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.value)
const showFull = computed(() => !showSummary.value)
const play = async () => {
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
go('queue')
}
</script> </script>
<style lang="postcss" scoped>
.artist-info {
.none {
margin-top: 1rem;
}
}
</style>

View file

@ -1,10 +1,10 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<article data-v-f01bdc56="" class="item full" draggable="true" tabindex="0" title="Led Zeppelin"><br data-v-f01bdc56="" data-testid="thumbnail" entity="[object Object]"> <article data-v-f01bdc56="" class="item tw-relative tw-flex md:tw-max-w-full tw-max-w-[256px] tw-border tw-p-5 tw-rounded-lg tw-flex-col tw-gap-5 tw-transition tw-border-color tw-duration-200 full" draggable="true" tabindex="0" title="Led Zeppelin"><br data-v-f01bdc56="" data-testid="thumbnail" entity="[object Object]">
<footer data-v-f01bdc56=""> <footer data-v-f01bdc56="" class="tw-flex tw-flex-1 tw-flex-col tw-gap-1.5 tw-overflow-hidden">
<div data-v-f01bdc56="" class="name"><a href="#/artist/42" class="text-normal" data-testid="name">Led Zeppelin</a></div> <div data-v-f01bdc56="" class="name tw-flex tw-flex-col tw-gap-2 tw-whitespace-nowrap"><a href="#/artist/42" class="text-normal" data-testid="name">Led Zeppelin</a></div>
<p data-v-f01bdc56="" class="meta"><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" class="download-artist" role="button"> Download </a></p> <p data-v-f01bdc56="" class="meta tw-text-[0.9rem] tw-flex tw-gap-1.5 tw-opacity-70 hover:tw-opacity-100"><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" class="download-artist" role="button"> Download </a></p>
</footer> </footer>
</article> </article>
`; `;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<nav data-v-0408531a="" class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu"> <nav data-v-0408531a="" class="artist-menu menu context-menu tw-select-none" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
<ul data-v-0408531a=""> <ul data-v-0408531a="">
<li>Play All</li> <li>Play All</li>
<li>Shuffle All</li> <li>Shuffle All</li>

View file

@ -1,12 +1,23 @@
<template> <template>
<form data-testid="forgot-password-form" @submit.prevent="requestResetPasswordLink"> <form
<h1 class="font-size-1.5">Forgot Password</h1> class="min-w-full sm:min-w-[480px] sm:bg-white/10 p-7 rounded-xl"
data-testid="forgot-password-form"
@submit.prevent="requestResetPasswordLink"
>
<h1 class="text-2xl mb-4">Forgot Password</h1>
<div> <FormRow>
<input v-model="email" placeholder="Your email address" required type="email"> <div class="flex flex-col gap-3 sm:flex-row sm:gap-0 sm:content-stretch">
<Btn :disabled="loading" type="submit">Reset Password</Btn> <TextInput
<Btn :disabled="loading" class="text-secondary" transparent @click="cancel">Cancel</Btn> v-model="email"
</div> placeholder="Your email address"
required type="email"
class="flex-1 sm:rounded-l sm:rounded-r-none"
/>
<Btn :disabled="loading" type="submit" class="sm:rounded-l-none sm:rounded-r">Reset Password</Btn>
<Btn :disabled="loading" class="!text-k-text-secondary" transparent @click="cancel">Cancel</Btn>
</div>
</FormRow>
</form> </form>
</template> </template>
@ -15,7 +26,9 @@ import { ref } from 'vue'
import { authService } from '@/services' import { authService } from '@/services'
import { useMessageToaster } from '@/composables' import { useMessageToaster } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const { toastSuccess, toastError } = useMessageToaster() const { toastSuccess, toastError } = useMessageToaster()
@ -37,50 +50,10 @@ const requestResetPasswordLink = async () => {
if (err.response.status === 404) { if (err.response.status === 404) {
toastError('No user with this email address found.') toastError('No user with this email address found.')
} else { } else {
toastError('An unknown error occurred.') toastError(err.response.data?.message || 'An unknown error occurred.')
} }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>
<style scoped lang="postcss">
form {
min-width: 480px;
@media screen and (max-width: 480px) {
min-width: 100%;
}
h1 {
margin-bottom: .75rem;
}
> div {
display: flex;
@media screen and (max-width: 480px) {
flex-direction: column;
gap: 1rem;
}
input {
flex: 1;
border-radius: var(--border-radius-input) 0 0 var(--border-radius-input);
@media screen and (max-width: 480px) {
border-radius: var(--border-radius-input);
}
}
[type=submit] {
border-radius: 0 var(--border-radius-input) var(--border-radius-input) 0;
@media screen and (max-width: 480px) {
border-radius: var(--border-radius-input);
}
}
}
}
</style>

View file

@ -1,30 +1,36 @@
<template> <template>
<div class="login-wrapper"> <div class="flex items-center justify-center min-h-screen my-0 mx-auto flex-col gap-5">
<form <form
v-show="!showingForgotPasswordForm" v-show="!showingForgotPasswordForm"
class="w-full sm:w-[288px] sm:border duration-500 p-7 rounded-xl border-transparent sm:bg-white/10 space-y-3"
:class="{ error: failed }" :class="{ error: failed }"
data-testid="login-form" data-testid="login-form"
@submit.prevent="login" @submit.prevent="login"
> >
<div class="logo"> <div class="text-center mb-8">
<img alt="Koel's logo" src="@/../img/logo.svg" width="156"> <img class="inline-block" alt="Koel's logo" src="@/../img/logo.svg" width="156">
</div> </div>
<input v-model="email" autofocus placeholder="Email Address" required type="email"> <FormRow>
<PasswordField v-model="password" placeholder="Password" required /> <TextInput v-model="email" autofocus placeholder="Email Address" required type="email" />
</FormRow>
<Btn type="submit">Log In</Btn> <FormRow>
<a <PasswordField v-model="password" placeholder="Password" required />
v-if="canResetPassword" </FormRow>
class="reset-password"
role="button" <FormRow>
@click.prevent="showForgotPasswordForm" <Btn type="submit">Log In</Btn>
> </FormRow>
Forgot password?
</a> <FormRow v-if="canResetPassword">
<a class="text-right text-[.95rem] text-k-text-secondary" role="button" @click.prevent="showForgotPasswordForm">
Forgot password?
</a>
</FormRow>
</form> </form>
<div v-if="ssoProviders.length" v-show="!showingForgotPasswordForm" class="sso"> <div v-if="ssoProviders.length" v-show="!showingForgotPasswordForm" class="flex gap-3 items-center">
<GoogleLoginButton v-if="ssoProviders.includes('Google')" @error="onSSOError" @success="onSSOSuccess" /> <GoogleLoginButton v-if="ssoProviders.includes('Google')" @error="onSSOError" @success="onSSOSuccess" />
</div> </div>
@ -38,10 +44,12 @@ import { authService } from '@/services'
import { logger } from '@/utils' import { logger } from '@/utils'
import { useMessageToaster } from '@/composables' import { useMessageToaster } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue' import PasswordField from '@/components/ui/form/PasswordField.vue'
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue' import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue' import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const DEMO_ACCOUNT = { const DEMO_ACCOUNT = {
email: 'demo@koel.dev', email: 'demo@koel.dev',
@ -111,52 +119,10 @@ const onSSOSuccess = (token: CompositeToken) => {
} }
} }
.login-wrapper {
min-height: 100vh;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
justify-content: center;
align-items: center;
}
.sso {
display: flex;
gap: 1rem;
justify-content: center;
}
form { form {
width: 276px;
padding: 1.8rem;
background: rgba(255, 255, 255, .08);
border-radius: .6rem;
border: 1px solid transparent;
transition: .5s;
display: flex;
flex-direction: column;
gap: 1rem;
&.error { &.error {
border-color: var(--color-red); @apply border-red-500;
animation: shake .5s; animation: shake .5s;
} }
.logo {
text-align: center;
}
.reset-password {
display: block;
text-align: right;
font-size: .95rem;
}
@media only screen and (max-width: 480px) {
width: 100vw;
border: 0;
background: transparent;
}
} }
</style> </style>

View file

@ -1,13 +1,15 @@
<template> <template>
<div class="reset-password-wrapper vertical-center"> <div class="flex items-center justify-center h-screen">
<form v-if="validPayload" @submit.prevent="submit"> <form
<h1 class="font-size-1.5">Set New Password</h1> v-if="validPayload"
<div> class="flex flex-col gap-3 sm:w-[480px] sm:bg-white/10 sm:rounded-lg p-7"
<label> @submit.prevent="submit"
<PasswordField v-model="password" minlength="10" placeholder="New password" required /> >
<span class="help">Min. 10 characters. Should be a mix of characters, numbers, and symbols.</span> <h1 class="text-2xl mb-2">Set New Password</h1>
</label> <label>
</div> <PasswordField v-model="password" minlength="10" placeholder="New password" required />
<span class="help block mt-4">Min. 10 characters. Should be a mix of characters, numbers, and symbols.</span>
</label>
<div> <div>
<Btn :disabled="loading" type="submit">Save</Btn> <Btn :disabled="loading" type="submit">Save</Btn>
</div> </div>
@ -21,8 +23,8 @@ import { authService } from '@/services'
import { base64Decode } from '@/utils' import { base64Decode } from '@/utils'
import { useMessageToaster, useRouter } from '@/composables' import { useMessageToaster, useRouter } from '@/composables'
import PasswordField from '@/components/ui/PasswordField.vue' import PasswordField from '@/components/ui/form/PasswordField.vue'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
const { getRouteParam, go } = useRouter() const { getRouteParam, go } = useRouter()
const { toastSuccess, toastError } = useMessageToaster() const { toastSuccess, toastError } = useMessageToaster()
@ -48,39 +50,9 @@ const submit = async () => {
await authService.login(email.value, password.value) await authService.login(email.value, password.value)
setTimeout(() => go('/', true)) setTimeout(() => go('/', true))
} catch (err: any) { } catch (err: any) {
toastError(err.response?.data?.message || 'Failed to set new password. Please try again.') toastError(err.response.data?.message || 'Failed to set new password. Please try again.')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>
<style scoped lang="postcss">
.reset-password-wrapper {
height: 100vh;
}
h1 {
margin-bottom: .75rem;
}
form {
width: 480px;
background: rgba(255, 255, 255, .08);
border-radius: .6rem;
padding: 1.8rem;
display: flex;
flex-direction: column;
gap: 1rem;
@media screen and (max-width: 480px) {
width: 100vw;
background: transparent;
}
.help {
display: block;
margin-top: .8rem;
}
}
</style>

View file

@ -1,10 +1,22 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<div data-v-0b0f87ea="" class="login-wrapper"> <div data-v-0b0f87ea="" class="vertical-center tw-min-h-screen tw-my-0 tw-mx-auto tw-flex-col tw-gap-5">
<form data-v-0b0f87ea="" class="" data-testid="login-form"> <form data-v-0b0f87ea="" class="sm:tw-w-[288px] sm:tw-border tw-duration-500 tw-p-7 tw-rounded-xl tw-border-transparent sm:tw-bg-white/10 tw-space-y-3" data-testid="login-form">
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email"> <div data-v-0b0f87ea="" class="tw-text-center tw-mb-8"><img data-v-0b0f87ea="" class="tw-inline-block" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" class="inset-when-pressed" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a> <!--v-if--><input data-v-2ca3bb69="" data-v-0b0f87ea="" class="tw-text-base tw-w-full tw-px-4 tw-py-2.5 tw-rounded" type="email" autofocus="" placeholder="Email Address" required="">
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if-->
<div data-v-e732892f="" data-v-0b0f87ea="" class="tw-relative"><input data-v-2ca3bb69="" data-v-e732892f="" class="tw-text-base tw-w-full tw-px-4 tw-py-2.5 tw-rounded tw-w-full" type="password" placeholder="Password" required=""><button data-v-e732892f="" type="button" class="tw-absolute tw-p-2.5 tw-right-0 tw-top-0"><br data-v-e732892f="" data-testid="Icon" icon="[object Object]"></button></div>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if--><button data-v-8943c846="" data-v-0b0f87ea="" class="inset-when-pressed tw-text-base tw-px-4 tw-py-2 tw-rounded tw-cursor-pointer" type="submit">Log In</button>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if--><a data-v-0b0f87ea="" class="tw-text-right tw-text-[.95rem]" role="button"> Forgot password? </a>
<!--v-if-->
</label>
</form> </form>
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
@ -12,12 +24,24 @@ exports[`renders 1`] = `
`; `;
exports[`shows Google login button 1`] = ` exports[`shows Google login button 1`] = `
<div data-v-0b0f87ea="" class="login-wrapper"> <div data-v-0b0f87ea="" class="vertical-center tw-min-h-screen tw-my-0 tw-mx-auto tw-flex-col tw-gap-5">
<form data-v-0b0f87ea="" class="" data-testid="login-form"> <form data-v-0b0f87ea="" class="sm:tw-w-[288px] sm:tw-border tw-duration-500 tw-p-7 tw-rounded-xl tw-border-transparent sm:tw-bg-white/10 tw-space-y-3" data-testid="login-form">
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email"> <div data-v-0b0f87ea="" class="tw-text-center tw-mb-8"><img data-v-0b0f87ea="" class="tw-inline-block" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" class="inset-when-pressed" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a> <!--v-if--><input data-v-2ca3bb69="" data-v-0b0f87ea="" class="tw-text-base tw-w-full tw-px-4 tw-py-2.5 tw-rounded" type="email" autofocus="" placeholder="Email Address" required="">
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if-->
<div data-v-e732892f="" data-v-0b0f87ea="" class="tw-relative"><input data-v-2ca3bb69="" data-v-e732892f="" class="tw-text-base tw-w-full tw-px-4 tw-py-2.5 tw-rounded tw-w-full" type="password" placeholder="Password" required=""><button data-v-e732892f="" type="button" class="tw-absolute tw-p-2.5 tw-right-0 tw-top-0"><br data-v-e732892f="" data-testid="Icon" icon="[object Object]"></button></div>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if--><button data-v-8943c846="" data-v-0b0f87ea="" class="inset-when-pressed tw-text-base tw-px-4 tw-py-2 tw-rounded tw-cursor-pointer" type="submit">Log In</button>
<!--v-if-->
</label><label data-v-0b0f87ea="" class="tw-flex tw-flex-col tw-gap-2 tw-text-[1.1rem]">
<!--v-if--><a data-v-0b0f87ea="" class="tw-text-right tw-text-[.95rem]" role="button"> Forgot password? </a>
<!--v-if-->
</label>
</form> </form>
<div data-v-0b0f87ea="" class="sso"><br data-v-0b0f87ea="" data-testid="google-login-button"></div> <div data-v-0b0f87ea="" class="tw-flex tw-gap-3 tw-items-center"><br data-v-0b0f87ea="" data-testid="google-login-button"></div>
<!--v-if--> <!--v-if-->
</div> </div>
`; `;

View file

@ -1,5 +1,10 @@
<template> <template>
<button title="Log in with Google" type="button" @click.prevent="loginWithGoogle"> <button
class="opacity-50 hover:opacity-100"
title="Log in with Google"
type="button"
@click.prevent="loginWithGoogle"
>
<img :src="googleLogo" alt="Google Logo" width="32" height="32"> <img :src="googleLogo" alt="Google Logo" width="32" height="32">
</button> </button>
</template> </template>
@ -22,13 +27,3 @@ const loginWithGoogle = async () => {
} }
} }
</script> </script>
<style scoped lang="postcss">
button {
opacity: .5;
&:hover {
opacity: 1;
}
}
</style>

View file

@ -1,42 +1,40 @@
<template> <template>
<div class="invitation-wrapper vertical-center"> <div class="flex items-center justify-center h-screen flex-col">
<form v-if="userProspect" autocomplete="off" @submit.prevent="submit"> <form
<header> v-if="userProspect"
autocomplete="off"
class="w-full sm:w-[320px] p-7 sm:bg-white/10 rounded-lg flex flex-col space-y-5"
@submit.prevent="submit"
>
<header class="mb-4">
Welcome to Koel! To accept the invitation, fill in the form below and click that button. Welcome to Koel! To accept the invitation, fill in the form below and click that button.
</header> </header>
<div class="form-row"> <FormRow>
<label> <template #label>Your email</template>
Your email <TextInput v-model="userProspect.email" disabled />
<input type="text" :value="userProspect.email" disabled> </FormRow>
</label>
</div>
<div class="form-row"> <FormRow>
<label> <template #label>Your name</template>
Your name <TextInput
<input v-model="name"
v-model="name" v-koel-focus
v-koel-focus data-testid="name"
data-testid="name" placeholder="Erm… Bruce Dickinson?"
placeholder="Erm… Bruce Dickinson?" required
required />
type="text" </FormRow>
>
</label>
</div>
<div class="form-row"> <FormRow>
<label> <template #label>Password</template>
Password <PasswordField v-model="password" data-testid="password" required />
<PasswordField v-model="password" data-testid="password" required /> <template #help>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</template>
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small> </FormRow>
</label>
</div>
<div class="form-row"> <FormRow>
<Btn type="submit" :disabled="loading">Accept &amp; Log In</Btn> <Btn type="submit" :disabled="loading">Accept &amp; Log In</Btn>
</div> </FormRow>
</form> </form>
<p v-if="!validToken">Invalid or expired invite.</p> <p v-if="!validToken">Invalid or expired invite.</p>
@ -47,14 +45,15 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { invitationService } from '@/services' import { invitationService } from '@/services'
import { useDialogBox, useRouter } from '@/composables' import { useDialogBox, useRouter } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
import { parseValidationError } from '@/utils' import { parseValidationError } from '@/utils'
import Btn from '@/components/ui/form/Btn.vue'
import PasswordField from '@/components/ui/form/PasswordField.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const { showErrorDialog } = useDialogBox() const { showErrorDialog } = useDialogBox()
const { getRouteParam, go } = useRouter() const { getRouteParam } = useRouter()
const name = ref('') const name = ref('')
const password = ref('') const password = ref('')
@ -91,42 +90,3 @@ onMounted(async () => {
} }
}) })
</script> </script>
<style scoped lang="postcss">
.invitation-wrapper {
display: flex;
height: 100vh;
flex-direction: column;
justify-content: center;
}
header {
margin-bottom: 1.2rem;
}
small {
margin-top: .8rem;
font-size: .9rem;
display: block;
line-height: 1.4;
color: var(--color-text-secondary);
}
form {
width: 320px;
padding: 1.8rem;
background: rgba(255, 255, 255, .08);
border-radius: .6rem;
display: flex;
flex-direction: column;
input {
width: 100%;
}
@media only screen and (max-width: 480px) {
border: 0;
background: transparent;
}
}
</style>

View file

@ -1,15 +1,15 @@
<template> <template>
<form class="license-form" @submit.prevent="validateLicenseKey"> <form class="license-form flex items-stretch" @submit.prevent="validateLicenseKey">
<input <TextInput
v-model="licenseKey" v-model="licenseKey"
v-koel-focus v-koel-focus
type="text" :disabled="loading"
class="!rounded-r-none"
name="license" name="license"
placeholder="Enter your license key" placeholder="Enter your license key"
required required
:disabled="loading" />
> <Btn :disabled="loading" class="!rounded-l-none" type="submit">Activate</Btn>
<Btn blue type="submit" :disabled="loading">Activate</Btn>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -18,7 +18,8 @@ import { plusService } from '@/services'
import { forceReloadWindow, logger } from '@/utils' import { forceReloadWindow, logger } from '@/utils'
import { useDialogBox } from '@/composables' import { useDialogBox } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
const { showSuccessDialog, showErrorDialog } = useDialogBox() const { showSuccessDialog, showErrorDialog } = useDialogBox()
const licenseKey = ref('') const licenseKey = ref('')
@ -38,23 +39,3 @@ const validateLicenseKey = async () => {
} }
} }
</script> </script>
<style scoped lang="postcss">
form {
display: flex;
align-items: stretch;
&:has(:focus) {
outline: 4px solid rgba(255, 255, 255, 0);
}
input {
border-radius: 4px 0 0 4px;
}
button {
border-radius: 0 4px 4px 0;
}
}
</style>

View file

@ -1,30 +1,18 @@
<template> <template>
<a href class="upgrade-to-plus-btn inset-when-pressed" @click.prevent="openModal"> <Btn
class="block w-full rounded-md px-4 py-3 text-white/80 hover:text-white bg-gradient-to-r to-[#c62be8] from-[#671ce4] !text-left"
@click.prevent="openModal"
>
<Icon :icon="faPlus" fixed-width /> <Icon :icon="faPlus" fixed-width />
Upgrade to Plus Upgrade to Plus
</a> </Btn>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { faPlus } from '@fortawesome/free-solid-svg-icons' import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import Btn from '@/components/ui/form/Btn.vue'
const openModal = () => eventBus.emit('MODAL_SHOW_KOEL_PLUS') const openModal = () => eventBus.emit('MODAL_SHOW_KOEL_PLUS')
</script> </script>
<style scoped lang="postcss">
a.upgrade-to-plus-btn {
background: linear-gradient(97.78deg, #671ce4 17.5%, #c62be8 113.39%);
border-radius: 5px;
border-style: solid;
padding: .65rem 1rem;
&:hover {
color: var(--color-text-primary) !important;
}
&:active {
padding: .65rem 1rem; /* prevent layout jump in sidebar */
}
}
</style>

View file

@ -1,31 +1,36 @@
<template> <template>
<div class="plus text-secondary" data-testid="koel-plus" tabindex="0"> <div class="plus text-k-text-secondary max-w-[480px] flex flex-col items-center" data-testid="koel-plus" tabindex="0">
<img class="plus-icon" alt="Koel Plus" src="@/../img/koel-plus.svg" width="96"> <img
class="-mt-[48px] rounded-full border-[6px] border-white"
alt="Koel Plus"
src="@/../img/koel-plus.svg"
width="96"
>
<main> <main class="!px-8 !py-5 text-center flex flex-col gap-5">
<div class="intro"> <div>
Koel Plus adds premium features on top of the default installation.<br> Koel Plus adds premium features on top of the default installation.<br>
Pay <em>once</em> and enjoy all additional features forever including those to be built into the app Pay <em>once</em> and enjoy all additional features forever including those to be built into the app
in the future! in the future!
</div> </div>
<div v-show="!showingActivateLicenseForm" class="buttons" data-testid="buttons"> <div v-show="!showingActivateLicenseForm" class="space-x-3" data-testid="buttons">
<Btn big red @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn> <Btn big danger @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn>
<Btn big green @click.prevent="showActivateLicenseForm">I have a license key</Btn> <Btn big success @click.prevent="showActivateLicenseForm">I have a license key</Btn>
</div> </div>
<div v-if="showingActivateLicenseForm" class="activate-form" data-testid="activateForm"> <div v-if="showingActivateLicenseForm" class="flex gap-3" data-testid="activateForm">
<ActivateLicenseForm v-if="showingActivateLicenseForm" /> <ActivateLicenseForm v-if="showingActivateLicenseForm" class="flex-1" />
<Btn transparent class="cancel" @click.prevent="hideActivateLicenseForm">Cancel</Btn> <Btn class="cancel" transparent @click.prevent="hideActivateLicenseForm">Cancel</Btn>
</div> </div>
<div class="more-info"> <div class="text-[0.9rem] opacity-70">
Visit <a href="https://koel.dev#plus" target="_blank">koel.dev</a> for more information. Visit <a href="https://koel.dev#plus" target="_blank">koel.dev</a> for more information.
</div> </div>
</main> </main>
<footer> <footer class="w-full text-center bg-black/20">
<Btn data-testid="close-modal-btn" red rounded @click.prevent="close">Close</Btn> <Btn data-testid="close-modal-btn" danger rounded @click.prevent="close">Close</Btn>
</footer> </footer>
</div> </div>
</template> </template>
@ -34,7 +39,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useKoelPlus } from '@/composables' import { useKoelPlus } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue' import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue'
const { checkoutUrl } = useKoelPlus() const { checkoutUrl } = useKoelPlus()
@ -54,63 +59,3 @@ const hideActivateLicenseForm = () => (showingActivateLicenseForm.value = false)
onMounted(() => window.createLemonSqueezy?.()) onMounted(() => window.createLemonSqueezy?.())
</script> </script>
<style scoped lang="postcss">
.plus {
max-width: 480px;
display: flex;
flex-direction: column;
align-items: center;
main {
padding: .7rem 1.7rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.plus-icon {
margin-top: calc(-48px);
border-radius: 99rem;
border: 6px solid #fff;
}
.intro {
text-align: center;
padding: .5rem 1.5rem;
}
.buttons {
display: flex;
justify-content: center;
gap: 1rem
}
.more-info {
font-size: .9rem;
opacity: .7;
}
.activate-form {
display: flex;
gap: .5rem;
form {
flex: 1;
}
button.cancel {
color: var(--color-text-secondary);
}
}
footer {
margin-top: .5rem;
width: 100%;
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, .2);
}
}
</style>

View file

@ -1,5 +1,10 @@
<template> <template>
<dialog ref="dialog" class="text-primary bg-primary" @cancel.prevent> <dialog
ref="dialog"
class="text-k-text-primary border-0 p-0 rounded-md min-w-[calc(100vm_-_24px)] md:min-w-[460px]
max-w-[calc(100vw_-_24px)] bg-k-bg-primary overflow-visible backdrop:bg-black/70"
@cancel.prevent
>
<Component :is="modalNameToComponentMap[activeModalName]" v-if="activeModalName" @close="close" /> <Component :is="modalNameToComponentMap[activeModalName]" v-if="activeModalName" @close="close" />
</dialog> </dialog>
</template> </template>
@ -23,7 +28,7 @@ const modalNameToComponentMap = {
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')), 'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')), 'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')), 'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue')) 'equalizer': defineAsyncComponent(() => import('@/components/ui/equalizer/Equalizer.vue'))
} }
type ModalName = keyof typeof modalNameToComponentMap type ModalName = keyof typeof modalNameToComponentMap
@ -87,62 +92,22 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
<style lang="postcss" scoped> <style lang="postcss" scoped>
dialog { dialog {
border: 0;
padding: 0;
border-radius: 4px;
min-width: 460px;
max-width: calc(100vw - 24px);
overflow: visible;
@media screen and (max-width: 768px) {
min-width: calc(100vw - 24px);
}
&::backdrop {
background: rgba(0, 0, 0, 0.7);
}
:deep(form), :deep(>div) { :deep(form), :deep(>div) {
position: relative; @apply relative;
> header, > main, > footer { > header, > main, > footer {
padding: 1.2rem; @apply px-6 py-5;
} }
> footer { > footer {
margin-top: 0; @apply mt-0 bg-black/10 border-t border-white/5 space-x-2;
background: rgba(0, 0, 0, 0.1);
border-top: 1px solid rgba(255, 255, 255, .05);
}
[type=text], [type=number], [type=email], [type=password], [type=url], [type=date], textarea, select {
width: 100%;
max-width: 100%;
height: 32px;
}
.warning {
color: var(--color-red);
}
textarea {
min-height: 192px;
}
> footer {
button + button {
margin-left: .5rem;
}
} }
> header { > header {
display: flex; @apply flex bg-k-bg-secondary;
background: var(--color-bg-secondary);
h1 { h1 {
font-size: 1.8rem; @apply text-3xl leading-normal overflow-hidden text-ellipsis whitespace-nowrap;
line-height: 2.2rem;
margin-bottom: .3rem;
} }
} }
} }

View file

@ -2,63 +2,42 @@
<!-- <!--
A very thin wrapper around Plyr, extracted as a standalone component for easier styling and to work better with HMR. A very thin wrapper around Plyr, extracted as a standalone component for easier styling and to work better with HMR.
--> -->
<div class="plyr"> <div class="plyr w-full h-[4px]">
<audio controls crossorigin="anonymous" /> <audio class="hidden" controls crossorigin="anonymous" />
</div> </div>
</template> </template>
<script lang="ts" setup>
</script>
<style lang="postcss"> <style lang="postcss">
/* can't be scoped as it would be overridden by the plyr css */ /* can't be scoped as it would be overridden by the plyr css */
.plyr { .plyr {
width: 100%;
height: 4px;
audio {
display: none;
}
.plyr__controls { .plyr__controls {
background: transparent; @apply bg-transparent shadow-none absolute top-0 w-full;
box-shadow: none; @apply p-0 !important;
padding: 0 !important;
position: absolute;
top: 0;
width: 100%;
} }
.plyr__progress--played[value] { .plyr__progress--played[value] {
transition: .3s ease-in-out; @apply transition duration-300 ease-in-out text-white/10;
color: rgba(255, 255, 255, .1);
:fullscreen & { :fullscreen & {
color: rgba(255, 255, 255, .5); @apply text-white/50 rounded-full overflow-hidden;
border-radius: 9999px;
overflow: hidden;
} }
} }
&:hover { &:hover {
.plyr__progress--played[value] { .plyr__progress--played[value] {
color: var(--color-highlight); @apply text-k-highlight;
} }
} }
@media(hover: none) { .plyr__progress--played[value] {
.plyr__progress--played[value] { @apply no-hover:text-k-highlight;
color: var(--color-highlight);
}
} }
:fullscreen & { :fullscreen & {
z-index: 4; @apply z-[4] bg-white/20 rounded-full;
background: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
.plyr__progress--played[value] { .plyr__progress--played[value] {
color: #fff !important; @apply text-white !important;
} }
} }
} }

View file

@ -0,0 +1,9 @@
<template>
<button
v-koel-tooltip.top
class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary"
type="button"
>
<slot />
</button>
</template>

View file

@ -1,38 +1,30 @@
<template> <template>
<div class="extra-controls" data-testid="other-controls"> <div class="extra-controls flex justify-end relative md:w-[320px] px-8 py-0">
<div class="wrapper"> <div class="flex justify-end items-center gap-6">
<button <FooterBtn
v-koel-tooltip.top class="visualizer-btn hidden md:!block"
class="visualizer-btn"
data-testid="toggle-visualizer-btn" data-testid="toggle-visualizer-btn"
title="Toggle visualizer" title="Toggle visualizer"
@click.prevent="toggleVisualizer" @click.prevent="toggleVisualizer"
> >
<Icon :icon="faBolt" /> <Icon :icon="faBolt" />
</button> </FooterBtn>
<button <FooterBtn
v-if="useEqualizer" v-if="useEqualizer"
v-koel-tooltip.top
:class="{ active: showEqualizer }" :class="{ active: showEqualizer }"
class="equalizer" class="equalizer"
title="Show equalizer" title="Show equalizer"
type="button"
@click.prevent="showEqualizer" @click.prevent="showEqualizer"
> >
<Icon :icon="faSliders" /> <Icon :icon="faSliders" />
</button> </FooterBtn>
<VolumeSlider /> <VolumeSlider />
<button <FooterBtn v-if="isFullscreenSupported()" :title="fullscreenButtonTitle" @click.prevent="toggleFullscreen">
v-if="isFullscreenSupported()"
v-koel-tooltip.top
:title="fullscreenButtonTitle"
@click.prevent="toggleFullscreen"
>
<Icon :icon="isFullscreen ? faCompress : faExpand" /> <Icon :icon="isFullscreen ? faCompress : faExpand" />
</button> </FooterBtn>
</div> </div>
</div> </div>
</template> </template>
@ -44,6 +36,7 @@ import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupporte
import { useRouter } from '@/composables' import { useRouter } from '@/composables'
import VolumeSlider from '@/components/ui/VolumeSlider.vue' import VolumeSlider from '@/components/ui/VolumeSlider.vue'
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
const isFullscreen = ref(false) const isFullscreen = ref(false)
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode')) const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
@ -64,44 +57,13 @@ onMounted(() => {
<style lang="postcss" scoped> <style lang="postcss" scoped>
.extra-controls { .extra-controls {
display: flex;
justify-content: flex-end;
position: relative;
width: 320px;
color: var(--color-text-secondary);
padding: 0 2rem;
:fullscreen & { :fullscreen & {
padding-right: 0; @apply pr-0;
}
.wrapper {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1.5rem;
}
button {
color: currentColor;
transition: color 0.2s ease-in-out;
&:hover {
color: var(--color-text-primary);
}
}
@media only screen and (max-width: 768px) {
width: auto;
.visualizer-btn {
display: none;
}
} }
:fullscreen & { :fullscreen & {
.visualizer-btn { .visualizer-btn {
display: none; @apply hidden;
} }
} }
} }

View file

@ -1,20 +1,20 @@
<template> <template>
<div class="playback-controls" data-testid="footer-middle-pane"> <div class="playback-controls flex flex-1 flex-col place-content-center place-items-center">
<div class="buttons"> <div class="flex items-center justify-center gap-5 md:gap-12">
<LikeButton v-if="song" :song="song" class="like-btn" /> <LikeButton v-if="song" :song="song" class="text-base" />
<button v-else type="button" /> <!-- a placeholder to maintain the flex layout --> <button v-else type="button" /> <!-- a placeholder to maintain the asymmetric layout -->
<button type="button" title="Play previous song" @click.prevent="playPrev"> <FooterBtn class="text-2xl" title="Play previous song" @click.prevent="playPrev">
<Icon :icon="faStepBackward" /> <Icon :icon="faStepBackward" />
</button> </FooterBtn>
<PlayButton /> <PlayButton />
<button type="button" title="Play next song" @click.prevent="playNext"> <FooterBtn class="text-2xl" title="Play next song" @click.prevent="playNext">
<Icon :icon="faStepForward" /> <Icon :icon="faStepForward" />
</button> </FooterBtn>
<RepeatModeSwitch class="repeat-mode-btn" /> <RepeatModeSwitch class="text-base" />
</div> </div>
</div> </div>
</template> </template>
@ -29,6 +29,7 @@ import { CurrentSongKey } from '@/symbols'
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue' import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
import LikeButton from '@/components/song/SongLikeButton.vue' import LikeButton from '@/components/song/SongLikeButton.vue'
import PlayButton from '@/components/ui/FooterPlayButton.vue' import PlayButton from '@/components/ui/FooterPlayButton.vue'
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
const song = requireInjection(CurrentSongKey, ref()) const song = requireInjection(CurrentSongKey, ref())
@ -37,44 +38,7 @@ const playNext = async () => await playbackService.playNext()
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.playback-controls { :fullscreen .playback-controls {
flex: 1; @apply scale-125;
display: flex;
flex-direction: column;
place-content: center;
place-items: center;
:fullscreen & {
transform: scale(1.2);
}
}
.buttons {
color: var(--color-text-secondary);
display: flex;
place-content: center;
place-items: center;
gap: 2rem;
@media screen and (max-width: 768px) {
gap: .75rem;
}
button {
color: currentColor;
font-size: 1.5rem;
width: 2.5rem;
aspect-ratio: 1/1;
transition: all .2s ease-in-out;
transition-property: color, border, transform;
&:hover {
color: var(--color-text-primary);
}
&.like-btn, &.repeat-mode-btn {
font-size: 1rem;
}
}
} }
</style> </style>

View file

@ -2,13 +2,18 @@
<div <div
:class="{ playing: song?.playback_state === 'Playing' }" :class="{ playing: song?.playback_state === 'Playing' }"
:draggable="draggable" :draggable="draggable"
class="song-info" class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5"
@dragstart="onDragStart" @dragstart="onDragStart"
> >
<span :style="{ backgroundImage: `url('${cover}')` }" class="album-thumb" /> <span class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover" />
<div v-if="song" class="meta"> <div v-if="song" class="meta overflow-hidden hidden md:block">
<h3 class="title">{{ song.title }}</h3> <h3 class="title text-ellipsis overflow-hidden whitespace-nowrap">{{ song.title }}</h3>
<a :href="`/#/artist/${song.artist_id}`" class="artist">{{ song.artist_name }}</a> <a
class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent"
:href="`/#/artist/${song.artist_id}`"
>
{{ song.artist_name }}
</a>
</div> </div>
</div> </div>
</template> </template>
@ -24,6 +29,7 @@ const { startDragging } = useDraggable('songs')
const song = requireInjection(CurrentSongKey, ref()) const song = requireInjection(CurrentSongKey, ref())
const cover = computed(() => song.value?.album_cover || defaultCover) const cover = computed(() => song.value?.album_cover || defaultCover)
const coverBackgroundImage = computed(() => `url(${ cover.value })`)
const draggable = computed(() => Boolean(song.value)) const draggable = computed(() => Boolean(song.value))
const onDragStart = (event: DragEvent) => { const onDragStart = (event: DragEvent) => {
@ -35,82 +41,35 @@ const onDragStart = (event: DragEvent) => {
<style lang="postcss" scoped> <style lang="postcss" scoped>
.song-info { .song-info {
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: flex-start;
width: 320px;
gap: 1rem;
:fullscreen & { :fullscreen & {
padding-left: 0; @apply pl-0;
}
@media screen and (max-width: 768px) {
width: 84px;
} }
.album-thumb { .album-thumb {
display: block; background-image: v-bind(coverBackgroundImage);
height: 75%;
aspect-ratio: 1;
border-radius: 50%;
background-size: cover;
@media screen and (max-width: 768px) {
height: 55%;
}
:fullscreen & { :fullscreen & {
height: 5rem; @apply h-20;
} }
} }
.meta { .meta {
overflow: hidden;
> * {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
@media screen and (max-width: 768px) {
display: none;
}
:fullscreen & { :fullscreen & {
margin-top: -18rem; @apply -mt-72 origin-bottom-left absolute overflow-hidden;
transform-origin: left bottom;
position: absolute;
overflow: hidden;
.title { .title {
font-size: 3rem; @apply text-5xl mb-[0.4rem] font-bold;
margin-bottom: .4rem;
line-height: 1.2;
font-weight: var(--font-weight-bold);
} }
.artist { .artist {
font-size: 1.6rem; @apply text-3xl w-fit;
width: fit-content;
line-height: 1.2;
} }
} }
} }
.artist {
display: block;
font-size: .9rem;
}
&.playing .album-thumb { &.playing .album-thumb {
@apply motion-reduce:animate-none;
animation: spin 30s linear infinite; animation: spin 30s linear infinite;
@media (prefers-reduced-motion) {
animation: none;
}
} }
} }

View file

@ -1,9 +1,9 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<div data-v-8bf5fe81="" class="extra-controls" data-testid="other-controls"> <div data-v-8bf5fe81="" class="extra-controls tw-flex tw-justify-end tw-relative md:tw-w-[320px] tw-px-8 tw-py-0">
<div data-v-8bf5fe81="" class="wrapper"><button data-v-8bf5fe81="" class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]"></button> <div data-v-8bf5fe81="" class="tw-flex tw-justify-end tw-items-center tw-gap-6"><button data-v-8bf5fe81="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)] visualizer-btn tw-hidden md:!tw-block" type="button" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]"></button>
<!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="volume muted"><span data-v-c7afcfc4="" role="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></span><span data-v-c7afcfc4="" role="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></span><input data-v-c7afcfc4="" class="plyr__volume" max="10" role="slider" step="0.1" title="Volume" type="range"></span> <!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="muted tw-hidden md:tw-flex tw-relative tw-items-center tw-gap-2"><button data-v-c7afcfc4="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-c7afcfc4="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><input data-v-c7afcfc4="" class="plyr__volume !tw-w-[120px] before:tw-absolute before:tw-left-0 before:tw-right-0 before:tw-top-[-12px] before:tw-bottom-[-12px]" max="10" role="slider" step="0.1" title="Volume" type="range"></span>
<!--v-if--> <!--v-if-->
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders with a current song 1`] = ` exports[`renders with a current song 1`] = `
<div data-v-2e8b419d="" class="playback-controls" data-testid="footer-middle-pane"> <div data-v-2e8b419d="" class="playback-controls tw-flex tw-flex-1 tw-flex-col tw-place-content-center tw-place-items-center">
<div data-v-2e8b419d="" class="buttons"><button data-v-2e8b419d="" title="Unlike Fahrstuhl to Heaven by Led Zeppelin" type="button" class="like-btn"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the flex layout --><button data-v-2e8b419d="" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon"> <div data-v-2e8b419d="" class="tw-text-2xl tw-flex tw-items-center tw-justify-center tw-gap-5 md:tw-gap-12"><button data-v-2e8b419d="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)] tw-text-base" type="button" title="Unlike Fahrstuhl to Heaven by Led Zeppelin"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="tw-opacity-30 tw-text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
<path d="m17 2 4 4-4 4"></path> <path d="m17 2 4 4-4 4"></path>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path> <path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path> <path d="m7 22-4-4 4-4"></path>
@ -12,8 +12,8 @@ exports[`renders with a current song 1`] = `
`; `;
exports[`renders without a current song 1`] = ` exports[`renders without a current song 1`] = `
<div data-v-2e8b419d="" class="playback-controls" data-testid="footer-middle-pane"> <div data-v-2e8b419d="" class="playback-controls tw-flex tw-flex-1 tw-flex-col tw-place-content-center tw-place-items-center">
<div data-v-2e8b419d="" class="buttons"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the flex layout --><button data-v-2e8b419d="" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon"> <div data-v-2e8b419d="" class="tw-text-2xl tw-flex tw-items-center tw-justify-center tw-gap-5 md:tw-gap-12"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="tw-transition-[color] tw-duration-200 tw-ease-in-out tw-text-[var(--color-text-secondary)] hover:tw-text-[var(--color-text-primary)]" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="tw-opacity-30 tw-text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
<path d="m17 2 4 4-4 4"></path> <path d="m17 2 4 4-4 4"></path>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path> <path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path> <path d="m7 22-4-4 4-4"></path>

View file

@ -1,15 +1,15 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders with current song 1`] = ` exports[`renders with current song 1`] = `
<div data-v-91ed60f7="" class="playing song-info" draggable="true"><span data-v-91ed60f7="" style="background-image: url(https://via.placeholder.com/150);" class="album-thumb"></span> <div data-v-91ed60f7="" class="playing song-info tw-px-6 tw-py-0 tw-flex tw-items-center tw-content-start tw-w-[84px] md:tw-w-80 tw-gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb tw-block tw-h-[55%] md:tw-h-3/4 tw-aspect-square tw-rounded-full tw-bg-cover"></span>
<div data-v-91ed60f7="" class="meta"> <div data-v-91ed60f7="" class="meta tw-overflow-hidden tw-hidden md:tw-block">
<h3 data-v-91ed60f7="" class="title">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artist/10" class="artist">Led Zeppelin</a> <h3 data-v-91ed60f7="" class="title tw-text-ellipsis tw-overflow-hidden tw-whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" class="artist tw-text-ellipsis tw-overflow-hidden tw-whitespace-nowrap tw-block tw-text-[0.9rem]" href="/#/artist/10">Led Zeppelin</a>
</div> </div>
</div> </div>
`; `;
exports[`renders with no current song 1`] = ` exports[`renders with no current song 1`] = `
<div data-v-91ed60f7="" class="song-info" draggable="false"><span data-v-91ed60f7="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="album-thumb"></span> <div data-v-91ed60f7="" class="song-info tw-px-6 tw-py-0 tw-flex tw-items-center tw-content-start tw-w-[84px] md:tw-w-80 tw-gap-5" draggable="false"><span data-v-91ed60f7="" class="album-thumb tw-block tw-h-[55%] md:tw-h-3/4 tw-aspect-square tw-rounded-full tw-bg-cover"></span>
<!--v-if--> <!--v-if-->
</div> </div>
`; `;

View file

@ -1,15 +1,15 @@
<template> <template>
<footer <footer
id="mainFooter"
ref="root" ref="root"
class="flex flex-col relative z-20 bg-k-bg-secondary h-k-footer-height"
@contextmenu.prevent="requestContextMenu" @contextmenu.prevent="requestContextMenu"
@mousemove="showControls" @mousemove="showControls"
> >
<AudioPlayer v-show="song" /> <AudioPlayer v-show="song" />
<div class="fullscreen-backdrop" :style="styles" /> <div class="fullscreen-backdrop hidden" />
<div class="wrapper"> <div class="wrapper relative flex flex-1">
<SongInfo /> <SongInfo />
<PlaybackControls /> <PlaybackControls />
<ExtraControls /> <ExtraControls />
@ -47,12 +47,9 @@ watch(song, async () => {
artist.value = await artistStore.resolve(song.value.artist_id) artist.value = await artistStore.resolve(song.value.artist_id)
}) })
const styles = computed(() => { const backgroundBackgroundImage = computed(() => {
const src = artist.value?.image ?? song.value?.album_cover const src = artist.value?.image ?? song.value?.album_cover
return src ? `url(${src})` : 'none'
return {
backgroundImage: src ? `url(${src})` : 'none'
}
}) })
const initPlaybackRelatedServices = async () => { const initPlaybackRelatedServices = async () => {
@ -89,7 +86,7 @@ const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(root)
watch(isFullscreen, fullscreen => { watch(isFullscreen, fullscreen => {
if (fullscreen) { if (fullscreen) {
setupControlHidingTimer() // setupControlHidingTimer()
root.value?.classList.remove('hide-controls') root.value?.classList.remove('hide-controls')
} else { } else {
window.clearTimeout(hideControlsTimeout) window.clearTimeout(hideControlsTimeout)
@ -101,84 +98,47 @@ eventBus.on('FULLSCREEN_TOGGLE', () => toggleFullscreen())
<style lang="postcss" scoped> <style lang="postcss" scoped>
footer { footer {
background-color: var(--color-bg-secondary);
background-size: 0;
height: var(--footer-height);
display: flex;
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2); box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
flex-direction: column;
position: relative;
z-index: 3;
.wrapper {
position: relative;
display: flex;
flex: 1;
}
.fullscreen-backdrop { .fullscreen-backdrop {
display: none; background-image: v-bind(backgroundBackgroundImage);
} }
&:fullscreen { &:fullscreen {
padding: calc(100vh - 9rem) 5vw 0; padding: calc(100vh - 9rem) 5vw 0;
background: none; @apply bg-none;
&.hide-controls :not(.fullscreen-backdrop) { &.hide-controls :not(.fullscreen-backdrop) {
transition: opacity 2s ease-in-out; transition: opacity 2s ease-in-out !important; /* overriding all children's custom transition, if any */
opacity: 0; @apply opacity-0;
} }
.wrapper { .wrapper {
z-index: 3; @apply z-[3]
} }
&::before { &::before {
background-color: #000; @apply bg-black bg-repeat absolute top-0 left-0 opacity-50 z-[1] pointer-events-none -m-[20rem];
content: '';
background-image: linear-gradient(135deg, #111 25%, transparent 25%), background-image: linear-gradient(135deg, #111 25%, transparent 25%),
linear-gradient(225deg, #111 25%, transparent 25%), linear-gradient(225deg, #111 25%, transparent 25%),
linear-gradient(45deg, #111 25%, transparent 25%), linear-gradient(45deg, #111 25%, transparent 25%),
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%); linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
background-position: 6px 0, 6px 0, 0 0, 0 0; background-position: 6px 0, 6px 0, 0 0, 0 0;
background-size: 6px 6px; background-size: 6px 6px;
background-repeat: repeat;
content: '';
position: absolute;
width: calc(100% + 40rem); width: calc(100% + 40rem);
height: calc(100% + 40rem); height: calc(100% + 40rem);
top: 0;
left: 0;
opacity: .5;
z-index: 1;
pointer-events: none;
margin: -20rem;
transform: rotate(10deg); transform: rotate(10deg);
} }
&::after { &::after {
background-image: linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(255, 255, 255, 0) 30vh); background-image: linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(255, 255, 255, 0) 30vh);
content: ''; content: '';
position: absolute; @apply absolute w-full h-full top-0 left-0 z-[1] pointer-events-none;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
pointer-events: none;
} }
.fullscreen-backdrop { .fullscreen-backdrop {
filter: saturate(.2); @apply saturate-[0.2] block absolute top-0 left-0 w-full h-full z-0 bg-cover bg-no-repeat bg-top;
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-size: cover;
background-repeat: no-repeat;
background-position: top center;
} }
} }
} }

View file

@ -1,253 +0,0 @@
<template>
<aside :class="{ 'showing-pane': activeTab }">
<div class="controls">
<div class="top">
<SidebarMenuToggleButton class="burger" />
<ExtraDrawerTabHeader v-if="song" v-model="activeTab" />
</div>
<div class="bottom">
<button
v-koel-tooltip.left
:title="shouldNotifyNewVersion ? 'New version available!' : 'About Koel'"
type="button"
@click.prevent="openAboutKoelModal"
>
<Icon :icon="faInfoCircle" />
<span v-if="shouldNotifyNewVersion" class="new-version-notification" />
</button>
<button v-koel-tooltip.left title="Log out" type="button" @click.prevent="logout">
<Icon :icon="faArrowRightFromBracket" />
</button>
<ProfileAvatar @click="onProfileLinkClick" />
</div>
</div>
<div v-if="song" v-show="activeTab" class="panes">
<div
v-show="activeTab === 'Lyrics'"
id="extraPanelLyrics"
aria-labelledby="extraTabLyrics"
role="tabpanel"
tabindex="0"
>
<LyricsPane :song="song" />
</div>
<div
v-show="activeTab === 'Artist'"
id="extraPanelArtist"
aria-labelledby="extraTabArtist"
role="tabpanel"
tabindex="0"
>
<ArtistInfo v-if="artist" :artist="artist" mode="aside" />
<span v-else>Loading</span>
</div>
<div
v-show="activeTab === 'Album'"
id="extraPanelAlbum"
aria-labelledby="extraTabAlbum"
role="tabpanel"
tabindex="0"
>
<AlbumInfo v-if="album" :album="album" mode="aside" />
<span v-else>Loading</span>
</div>
<div
v-show="activeTab === 'YouTube'"
id="extraPanelYouTube"
data-testid="extra-drawer-youtube"
aria-labelledby="extraTabYouTube"
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="useYouTube && song" :song="song" />
</div>
</div>
</aside>
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { faArrowRightFromBracket, faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { albumStore, artistStore, preferenceStore } from '@/stores'
import { useAuthorization, useNewVersionNotification, useThirdPartyServices } from '@/composables'
import { eventBus, logger, requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import ProfileAvatar from '@/components/ui/ProfileAvatar.vue'
import SidebarMenuToggleButton from '@/components/ui/SidebarMenuToggleButton.vue'
const LyricsPane = defineAsyncComponent(() => import('@/components/ui/LyricsPane.vue'))
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
const YouTubeVideoList = defineAsyncComponent(() => import('@/components/ui/YouTubeVideoList.vue'))
const ExtraDrawerTabHeader = defineAsyncComponent(() => import('@/components/ui/ExtraDrawerTabHeader.vue'))
const { currentUser } = useAuthorization()
const { useYouTube } = useThirdPartyServices()
const { shouldNotifyNewVersion } = useNewVersionNotification()
const song = requireInjection(CurrentSongKey, ref(undefined))
const activeTab = ref<ExtraPanelTab | null>(null)
const artist = ref<Artist>()
const album = ref<Album>()
const fetchSongInfo = async (_song: Song) => {
song.value = _song
artist.value = undefined
album.value = undefined
try {
artist.value = await artistStore.resolve(_song.artist_id)
album.value = await albumStore.resolve(_song.album_id)
} catch (error) {
logger.log('Failed to fetch media information', error)
}
}
watch(song, song => song && fetchSongInfo(song), { immediate: true })
watch(activeTab, tab => (preferenceStore.active_extra_panel_tab = tab))
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
const onProfileLinkClick = () => isMobile.any && (activeTab.value = null)
const logout = () => eventBus.emit('LOG_OUT')
onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_panel_tab))
</script>
<style lang="postcss" scoped>
aside {
display: flex;
flex-direction: row-reverse;
color: var(--color-text-secondary);
height: var(--header-height);
z-index: 2;
@media screen and (max-width: 768px) {
background-color: var(--color-bg-primary);
background-image: var(--bg-image);
background-attachment: var(--bg-attachment);
background-size: var(--bg-size);
background-position: var(--bg-position);
flex-direction: column;
position: fixed;
top: 0;
width: 100%;
&.showing-pane {
height: 100%;
}
}
}
.panes {
width: var(--extra-drawer-width);
padding: 2rem 1.7rem;
background: var(--color-bg-secondary);
overflow: auto;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
@media (hover: none) {
/* Enable scroll with momentum on touch devices */
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
@media screen and (max-width: 768px) {
width: 100%;
height: calc(100vh - var(--header-height) - var(--footer-height));
}
}
.controls {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100%;
width: 64px;
padding: 1.6rem 0 1.2rem;
background-color: rgba(0, 0, 0, .05);
border-left: 1px solid rgba(255, 255, 255, .05);
@media screen and (max-width: 768px) {
z-index: 2;
height: auto;
width: 100%;
flex-direction: row;
padding: .5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, .05);
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .5);
}
.top, .bottom {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 1rem;
@media screen and (max-width: 768px) {
flex-direction: row;
gap: .25rem;
}
}
:deep(button) {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
aspect-ratio: 1/1;
border-radius: 999rem;
background: rgba(0, 0, 0, .3);
font-size: 1.2rem;
opacity: .7;
transition: opacity .2s ease-in-out;
color: currentColor;
cursor: pointer;
@media screen and (max-width: 768px) {
background: none;
}
&:hover, &.active {
opacity: 1;
color: var(--color-text-primary);
background: rgba(255, 255, 255, .1);
}
&:active {
transform: scale(.9);
}
&.burger {
display: none;
@media screen and (max-width: 768px) {
display: block;
}
}
}
.new-version-notification {
position: absolute;
width: 10px;
height: 10px;
background: var(--color-highlight);
right: 1px;
top: 1px;
border-radius: 50%;
}
}
</style>

View file

@ -1,5 +1,8 @@
<template> <template>
<section id="mainContent"> <section
id="mainContent"
class="flex-1 relative overflow-hidden"
>
<!-- <!--
Most of the views are render-expensive and have their own UI states (viewport/scroll position), e.g. the song Most of the views are render-expensive and have their own UI states (viewport/scroll position), e.g. the song
lists), so we use v-show. lists), so we use v-show.
@ -73,60 +76,5 @@ const screen = ref<ScreenName>('Home')
onRouteChanged(route => (screen.value = route.screen)) onRouteChanged(route => (screen.value = route.screen))
onMounted(async () => { onMounted(() => (screen.value = getCurrentScreen()))
screen.value = getCurrentScreen()
})
</script> </script>
<style lang="postcss">
#mainContent {
flex: 1;
position: relative;
overflow: hidden;
> section {
max-height: 100%;
min-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
.main-scroll-wrap {
overflow: scroll;
display: flex;
flex-direction: column;
padding: 1.5rem;
@supports (scrollbar-gutter: stable) {
overflow: auto;
scrollbar-gutter: stable;
@media (hover: none) {
scrollbar-gutter: auto;
}
}
flex: 1;
-ms-overflow-style: -ms-autohiding-scrollbar;
place-content: start;
@media (hover: none) {
/* Enable scroll with momentum on touch devices */
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
}
}
@media screen and (max-width: 768px) {
> section {
/* Leave some space for the "Back to top" button */
.main-scroll-wrap {
padding-bottom: 64px;
}
}
}
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<ExtraDrawerButton
v-koel-tooltip.left
:title="shouldNotifyNewVersion ? 'New version available!' : 'About Koel'"
@click.prevent="openAboutKoelModal"
>
<Icon :icon="faInfoCircle" />
<span
v-if="shouldNotifyNewVersion"
class="absolute w-[10px] aspect-square right-px top-px rounded-full bg-k-highlight"
/>
</ExtraDrawerButton>
</template>
<script setup lang="ts">
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'
import { useNewVersionNotification } from '@/composables'
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
const { shouldNotifyNewVersion } = useNewVersionNotification()
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
</script>

View file

@ -0,0 +1,150 @@
<template>
<aside
:class="{ 'showing-pane': activeTab }"
class="fixed sm:relative top-0 w-screen md:w-auto flex flex-col md:flex-row-reverse z-[2] text-k-text-secondary"
>
<div
class="controls flex md:flex-col justify-between items-center md:w-[64px] md:py-6 tw:px-0
bg-black/5 md:border-l border-solid md:border-l-white/5 md:border-b-0 md:shadow-none
z-[2] w-screen flex-row border-b border-b-white/5 border-l-0 shadow-xl
py-0 px-6 h-k-header-height"
>
<div class="btn-group">
<SidebarMenuToggleButton />
<ExtraDrawerTabHeader v-if="song" v-model="activeTab" />
</div>
<div class="btn-group">
<AboutKoelButton />
<LogoutButton />
<ProfileAvatar @click="onProfileLinkClick" />
</div>
</div>
<div v-if="song" v-show="activeTab" class="panes py-8 px-6 overflow-auto bg-k-bg-secondary">
<div
v-show="activeTab === 'Lyrics'"
id="extraPanelLyrics"
aria-labelledby="extraTabLyrics"
role="tabpanel"
tabindex="0"
>
<LyricsPane :song="song" />
</div>
<div
v-show="activeTab === 'Artist'"
id="extraPanelArtist"
aria-labelledby="extraTabArtist"
role="tabpanel"
tabindex="0"
>
<ArtistInfo v-if="artist" :artist="artist" mode="aside" />
<span v-else>Loading</span>
</div>
<div
v-show="activeTab === 'Album'"
id="extraPanelAlbum"
aria-labelledby="extraTabAlbum"
role="tabpanel"
tabindex="0"
>
<AlbumInfo v-if="album" :album="album" mode="aside" />
<span v-else>Loading</span>
</div>
<div
v-show="activeTab === 'YouTube'"
id="extraPanelYouTube"
data-testid="extra-drawer-youtube"
aria-labelledby="extraTabYouTube"
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="useYouTube && song" :song="song" />
</div>
</div>
</aside>
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { albumStore, artistStore, preferenceStore } from '@/stores'
import { useThirdPartyServices } from '@/composables'
import { logger, requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import ProfileAvatar from '@/components/ui/ProfileAvatar.vue'
import SidebarMenuToggleButton from '@/components/ui/SidebarMenuToggleButton.vue'
import AboutKoelButton from '@/components/layout/main-wrapper/extra-drawer/AboutKoelButton.vue'
import LogoutButton from '@/components/layout/main-wrapper/extra-drawer/LogoutButton.vue'
const LyricsPane = defineAsyncComponent(() => import('@/components/ui/LyricsPane.vue'))
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
const YouTubeVideoList = defineAsyncComponent(() => import('@/components/ui/youtube/YouTubeVideoList.vue'))
const ExtraDrawerTabHeader = defineAsyncComponent(() => import('./ExtraDrawerTabHeader.vue'))
const { useYouTube } = useThirdPartyServices()
const song = requireInjection(CurrentSongKey, ref(undefined))
const activeTab = ref<ExtraPanelTab | null>(null)
const artist = ref<Artist>()
const album = ref<Album>()
const fetchSongInfo = async (_song: Song) => {
song.value = _song
artist.value = undefined
album.value = undefined
try {
artist.value = await artistStore.resolve(_song.artist_id)
album.value = await albumStore.resolve(_song.album_id)
} catch (error) {
logger.log('Failed to fetch media information', error)
}
}
watch(song, song => song && fetchSongInfo(song), { immediate: true })
watch(activeTab, tab => (preferenceStore.active_extra_panel_tab = tab))
const onProfileLinkClick = () => isMobile.any && (activeTab.value = null)
onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_panel_tab))
</script>
<style lang="postcss" scoped>
@import '@/../css/partials/mixins.pcss';
@tailwind utilities;
@layer utilities {
.btn-group {
@apply flex md:flex-col justify-between items-center gap-1 md:gap-3
}
}
aside {
@media screen and (max-width: 768px) {
@mixin themed-background;
&.showing-pane {
height: 100%;
}
}
}
.panes {
@apply no-hover:overflow-y-auto w-k-extra-drawer-width;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
@media screen and (max-width: 768px) {
width: 100%;
height: calc(100vh - var(--header-height) - var(--footer-height));
}
}
</style>

View file

@ -0,0 +1,24 @@
<template>
<button
class="relative flex items-center justify-center h-[42px] aspect-square rounded-full
bg-none md:bg-black/30 text-xl opacity-70 transition-opacity duration-200 ease-in-out text-current cursor-pointer
hover:active-state active:scale-90"
type="button"
>
<slot />
</button>
</template>
<style scoped lang="postcss">
@tailwind utilities;
@layer utilities {
.active-state {
@apply opacity-100 text-k-text-primary bg-white/10;
}
}
button.active {
@apply active-state;
}
</style>

View file

@ -1,45 +1,41 @@
<template> <template>
<button <ExtraDrawerButton
id="extraTabLyrics" id="extraTabLyrics"
v-koel-tooltip.left v-koel-tooltip.left
:class="{ active: value === 'Lyrics' }" :class="{ active: value === 'Lyrics' }"
title="Lyrics" title="Lyrics"
type="button"
@click.prevent="toggleTab('Lyrics')" @click.prevent="toggleTab('Lyrics')"
> >
<Icon :icon="faFeather" fixed-width /> <Icon :icon="faFeather" fixed-width />
</button> </ExtraDrawerButton>
<button <ExtraDrawerButton
id="extraTabArtist" id="extraTabArtist"
v-koel-tooltip.left v-koel-tooltip.left
:class="{ active: value === 'Artist' }" :class="{ active: value === 'Artist' }"
title="Artist information" title="Artist information"
type="button"
@click.prevent="toggleTab('Artist')" @click.prevent="toggleTab('Artist')"
> >
<Icon :icon="faMicrophone" fixed-width /> <Icon :icon="faMicrophone" fixed-width />
</button> </ExtraDrawerButton>
<button <ExtraDrawerButton
id="extraTabAlbum" id="extraTabAlbum"
v-koel-tooltip.left v-koel-tooltip.left
:class="{ active: value === 'Album' }" :class="{ active: value === 'Album' }"
title="Album information" title="Album information"
type="button"
@click.prevent="toggleTab('Album')" @click.prevent="toggleTab('Album')"
> >
<Icon :icon="faCompactDisc" fixed-width /> <Icon :icon="faCompactDisc" fixed-width />
</button> </ExtraDrawerButton>
<button <ExtraDrawerButton
v-if="useYouTube" v-if="useYouTube"
id="extraTabYouTube" id="extraTabYouTube"
v-koel-tooltip.left v-koel-tooltip.left
:class="{ active: value === 'YouTube' }" :class="{ active: value === 'YouTube' }"
title="Related YouTube videos" title="Related YouTube videos"
type="button"
@click.prevent="toggleTab('YouTube')" @click.prevent="toggleTab('YouTube')"
> >
<Icon :icon="faYoutube" fixed-width /> <Icon :icon="faYoutube" fixed-width />
</button> </ExtraDrawerButton>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -47,6 +43,7 @@ import { faCompactDisc, faFeather, faMicrophone } from '@fortawesome/free-solid-
import { faYoutube } from '@fortawesome/free-brands-svg-icons' import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { computed } from 'vue' import { computed } from 'vue'
import { useThirdPartyServices } from '@/composables' import { useThirdPartyServices } from '@/composables'
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), { const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), {
modelValue: null modelValue: null
@ -63,7 +60,3 @@ const value = computed({
const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? null : tab) const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? null : tab)
</script> </script>
<style scoped>
</style>

View file

@ -0,0 +1,14 @@
<template>
<ExtraDrawerButton v-koel-tooltip.left title="Log out" @click.prevent="logout">
<Icon :icon="faArrowRightFromBracket" />
</ExtraDrawerButton>
</template>
<script setup lang="ts">
import { faArrowRightFromBracket } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
const logout = () => eventBus.emit('LOG_OUT')
</script>

View file

@ -0,0 +1,15 @@
// Vitest Snapshot v1
exports[`renders without a current song 1`] = `
<aside data-v-847f0b60="" class="tw-fixed sm:tw-relative tw-top-0 tw-w-screen md:tw-w-auto tw-flex tw-flex-col md:tw-flex-row-reverse tw-z-[2]">
<div data-v-847f0b60="" class="controls tw-flex md:tw-flex-col tw-justify-between tw-items-center md:tw-w-[64px] md:tw-py-6 tw:tw-px-0 tw-bg-black/5 md:tw-border-l tw-border-solid md:tw-border-l-white/5 md:tw-border-b-0 md:tw-shadow-none tw-z-[2] tw-w-screen tw-flex-row tw-border-b tw-border-b-white/5 tw-border-l-0 tw-shadow-xl tw-py-0 tw-px-6 header-height">
<div data-v-847f0b60="" class="btn-group"><button data-v-5e31283f="" data-v-847f0b60="" class="tw-flex tw-items-center tw-justify-center tw-h-[42px] tw-aspect-square tw-rounded-full tw-bg-none md:tw-bg-black/30 tw-text-xl tw-opacity-70 tw-transition-opacity tw-duration-200 tw-ease-in-out tw-text-current tw-cursor-pointer hover:active-state active:tw-scale-90 tw-block md:tw-hidden" type="button"><br data-testid="Icon" icon="[object Object]"></button>
<!--v-if-->
</div>
<div data-v-847f0b60="" class="btn-group"><button data-v-5e31283f="" data-v-847f0b60="" class="tw-flex tw-items-center tw-justify-center tw-h-[42px] tw-aspect-square tw-rounded-full tw-bg-none md:tw-bg-black/30 tw-text-xl tw-opacity-70 tw-transition-opacity tw-duration-200 tw-ease-in-out tw-text-current tw-cursor-pointer hover:active-state active:tw-scale-90" type="button" title="About Koel"><br data-testid="Icon" icon="[object Object]">
<!--v-if-->
</button><button data-v-5e31283f="" data-v-847f0b60="" class="tw-flex tw-items-center tw-justify-center tw-h-[42px] tw-aspect-square tw-rounded-full tw-bg-none md:tw-bg-black/30 tw-text-xl tw-opacity-70 tw-transition-opacity tw-duration-200 tw-ease-in-out tw-text-current tw-cursor-pointer hover:active-state active:tw-scale-90" type="button" title="Log out"><br data-testid="Icon" icon="[object Object]"></button><br data-v-847f0b60="" data-testid="stub"></div>
</div>
<!--v-if-->
</aside>
`;

View file

@ -1,5 +1,5 @@
<template> <template>
<div id="mainWrapper"> <div class="relative flex flex-1 overflow-hidden">
<SideBar /> <SideBar />
<MainContent /> <MainContent />
<ExtraDrawer /> <ExtraDrawer />
@ -12,17 +12,7 @@ import { defineAsyncComponent } from 'vue'
import SideBar from '@/components/layout/main-wrapper/sidebar/Sidebar.vue' import SideBar from '@/components/layout/main-wrapper/sidebar/Sidebar.vue'
import MainContent from '@/components/layout/main-wrapper/MainContent.vue' import MainContent from '@/components/layout/main-wrapper/MainContent.vue'
import ExtraDrawer from '@/components/layout/main-wrapper/ExtraDrawer.vue' import ExtraDrawer from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawer.vue'
const ModalWrapper = defineAsyncComponent(() => import('@/components/layout/ModalWrapper.vue')) const ModalWrapper = defineAsyncComponent(() => import('@/components/layout/ModalWrapper.vue'))
</script> </script>
<style lang="postcss">
#mainWrapper {
position: relative;
display: flex;
flex: 1;
height: 0; /* fix a flex-box bug https://github.com/philipwalton/flexbugs/issues/197#issuecomment-378908438 */
overflow: hidden;
}
</style>

View file

@ -1,31 +1,42 @@
<template> <template>
<li <li
class="playlist-folder"
:class="{ droppable }" :class="{ droppable }"
tabindex="0" class="playlist-folder relative"
draggable="true" draggable="true"
tabindex="0"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@dragover="onDragOver" @dragover="onDragOver"
@dragstart="onDragStart" @dragstart="onDragStart"
@drop="onDrop" @drop="onDrop"
> >
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu"> <ul>
<Icon :icon="opened ? faFolderOpen : faFolder" fixed-width /> <SidebarItem @click="toggle" @contextmenu.prevent="onContextMenu">
<span>{{ folder.name }}</span> <template #icon>
</a> <Icon :icon="opened ? faFolderOpen : faFolder" fixed-width />
</template>
{{ folder.name }}
</SidebarItem>
<ul v-if="playlistsInFolder.length" v-show="opened"> <li v-if="playlistsInFolder.length" v-show="opened">
<PlaylistSidebarItem v-for="playlist in playlistsInFolder" :key="playlist.id" :list="playlist" class="sub-item" /> <ul>
<PlaylistSidebarItem
v-for="playlist in playlistsInFolder"
:key="playlist.id"
:list="playlist"
class="pl-4"
/>
</ul>
</li>
<li
v-if="opened"
:class="droppableOnHatch && 'droppable'"
class="hatch absolute bottom-0 w-full h-1"
@dragover="onDragOverHatch"
@dragleave.prevent="onDragLeaveHatch"
@drop.prevent="onDropOnHatch"
/>
</ul> </ul>
<div
v-if="opened"
:class="droppableOnHatch && 'droppable'"
class="hatch"
@dragover="onDragOverHatch"
@dragleave.prevent="onDragLeaveHatch"
@drop.prevent="onDropOnHatch"
/>
</li> </li>
</template> </template>
@ -36,7 +47,8 @@ import { playlistFolderStore, playlistStore } from '@/stores'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { useDraggable, useDroppable } from '@/composables' import { useDraggable, useDroppable } from '@/composables'
import PlaylistSidebarItem from '@/components/layout/main-wrapper/sidebar/PlaylistSidebarItem.vue' import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import SidebarItem from './SidebarItem.vue'
const props = defineProps<{ folder: PlaylistFolder }>() const props = defineProps<{ folder: PlaylistFolder }>()
const { folder } = toRefs(props) const { folder } = toRefs(props)
@ -108,28 +120,11 @@ const onContextMenu = (event: MouseEvent) => eventBus.emit(
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
li.playlist-folder { .droppable {
position: relative; @apply ring-1 ring-offset-0 ring-k-accent rounded-md cursor-copy;
}
a { .hatch.droppable {
color: var(--color-text-secondary); @apply border-b-[3px] border-k-highlight;
}
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.hatch {
position: absolute;
bottom: 0;
width: 100%;
height: .5rem;
&.droppable {
border-bottom: 3px solid var(--color-highlight);
}
}
} }
</style> </style>

View file

@ -1,8 +1,8 @@
<template> <template>
<li <SidebarItem
ref="el" :class="{ current, droppable }"
:class="{ droppable }" :href="url"
class="playlist" class="playlist select-none"
draggable="true" draggable="true"
@contextmenu="onContextMenu" @contextmenu="onContextMenu"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@ -10,15 +10,15 @@
@dragstart="onDragStart" @dragstart="onDragStart"
@drop="onDrop" @drop="onDrop"
> >
<a :class="{ active }" :href="url"> <template #icon>
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width /> <Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-k-success" fixed-width />
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width /> <Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-k-love" fixed-width />
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width /> <Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
<Icon v-else-if="list.is_collaborative" :icon="faUsers" fixed-width /> <Icon v-else-if="list.is_collaborative" :icon="faUsers" fixed-width />
<ListMusic v-else :size="16" /> <ListMusic v-else :size="16" />
<span>{{ list.name }}</span> </template>
</a> {{ list.name }}
</li> </SidebarItem>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -34,6 +34,8 @@ import { eventBus } from '@/utils'
import { favoriteStore } from '@/stores' import { favoriteStore } from '@/stores'
import { useDraggable, useDroppable, usePlaylistManagement, useRouter } from '@/composables' import { useDraggable, useDroppable, usePlaylistManagement, useRouter } from '@/composables'
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
const { onRouteChanged } = useRouter() const { onRouteChanged } = useRouter()
const { startDragging } = useDraggable('playlist') const { startDragging } = useDraggable('playlist')
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist']) const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
@ -49,7 +51,7 @@ const isPlaylist = (list: PlaylistLike): list is Playlist => 'id' in list
const isFavoriteList = (list: PlaylistLike): list is FavoriteList => list.name === 'Favorites' const isFavoriteList = (list: PlaylistLike): list is FavoriteList => list.name === 'Favorites'
const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList => list.name === 'Recently Played' const isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList => list.name === 'Recently Played'
const active = ref(false) const current = ref(false)
const url = computed(() => { const url = computed(() => {
if (isPlaylist(list.value)) return `#/playlist/${list.value.id}` if (isPlaylist(list.value)) return `#/playlist/${list.value.id}`
@ -109,38 +111,26 @@ const onDrop = async (event: DragEvent) => {
onRouteChanged(route => { onRouteChanged(route => {
switch (route.screen) { switch (route.screen) {
case 'Favorites': case 'Favorites':
active.value = isFavoriteList(list.value) current.value = isFavoriteList(list.value)
break break
case 'RecentlyPlayed': case 'RecentlyPlayed':
active.value = isRecentlyPlayedList(list.value) current.value = isRecentlyPlayedList(list.value)
break break
case 'Playlist': case 'Playlist':
active.value = (list.value as Playlist).id === route.params!.id current.value = (list.value as Playlist).id === route.params!.id
break break
default: default:
active.value = false current.value = false
break break
} }
}) })
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.playlist { .droppable {
user-select: none; @apply ring-1 ring-offset-0 ring-k-accent rounded-md cursor-copy;
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
:deep(a) {
span {
pointer-events: none;
}
}
} }
</style> </style>

View file

@ -1,82 +0,0 @@
<template>
<section id="playlists">
<h1>
<span class="heading">Playlists</span>
<button
ref="createBtnEl"
type="button"
title="Create a new playlist or folder"
@click.stop.prevent="requestContextMenu"
>
<Icon :icon="faCirclePlus" />
</button>
</h1>
<ul>
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }" />
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }" />
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" />
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" />
</ul>
</section>
</template>
<script lang="ts" setup>
import { faCirclePlus } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRef } from 'vue'
import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores'
import { eventBus } from '@/utils'
import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
const createBtnEl = ref<HTMLElement>()
const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
if (folder_id === null) return true
// if the playlist's folder is not found, it's an orphan
// this can happen if the playlist belongs to another user (collaborative playlist)
return !folders.value.find(folder => folder.id === folder_id)
}))
const requestContextMenu = () => {
const clientRect = createBtnEl.value!.getBoundingClientRect()
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', {
top: clientRect.bottom,
left: clientRect.right
})
}
</script>
<style lang="postcss">
#playlists {
h1 {
display: flex;
align-items: center;
span.heading {
flex: 1;
}
button {
position: relative;
&::before {
/* increase clickable area */
content: '';
position: absolute;
width: 28px;
height: 28px;
top: -6px;
left: -6px;
cursor: pointer;
}
}
}
}
</style>

View file

@ -2,12 +2,14 @@
<SidebarItem <SidebarItem
screen="Queue" screen="Queue"
href="#/queue" href="#/queue"
:icon="faListOl"
:class="droppable && 'droppable'" :class="droppable && 'droppable'"
@dragleave="onQueueDragLeave" @dragleave="onQueueDragLeave"
@dragover.prevent="onQueueDragOver" @dragover.prevent="onQueueDragOver"
@drop="onQueueDrop" @drop="onQueueDrop"
> >
<template #icon>
<Icon :icon="faListOl" fixed-width />
</template>
Current Queue Current Queue
</SidebarItem> </SidebarItem>
</template> </template>

View file

@ -1,4 +1,4 @@
import { expect, it } from 'vitest' import { it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import { commonStore } from '@/stores' import { commonStore } from '@/stores'

View file

@ -1,108 +1,69 @@
<template> <template>
<nav <nav
id="sidebar"
v-koel-clickaway="closeIfMobile" v-koel-clickaway="closeIfMobile"
:class="{ collapsed, 'tmp-showing': tmpShowing, showing: mobileShowing }" :class="{ collapsed: !expanded, 'tmp-showing': tmpShowing, showing: mobileShowing }"
class="side side-nav" class="flex flex-col pb-4 fixed md:relative w-full md:w-k-sidebar-width z-10"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
> >
<section class="search-wrapper"> <section class="search-wrapper p-6">
<SearchForm /> <SearchForm />
</section> </section>
<section v-koel-overflow-fade class="menu-wrapper"> <section v-koel-overflow-fade class="py-0 px-6 overflow-y-auto space-y-8">
<section class="music"> <SidebarYourMusicSection />
<h1>Your Music</h1> <SidebarPlaylistsSection />
<SidebarManageSection v-if="showManageSection" />
<ul class="menu">
<SidebarItem screen="Home" href="#/home" :icon="faHome">Home</SidebarItem>
<QueueSidebarItem />
<SidebarItem screen="Songs" href="#/songs" :icon="faMusic">All Songs</SidebarItem>
<SidebarItem screen="Albums" href="#/albums" :icon="faCompactDisc">Albums</SidebarItem>
<SidebarItem screen="Artists" href="#/artists" :icon="faMicrophone">Artists</SidebarItem>
<SidebarItem screen="Genres" href="#/genres" :icon="faTags">Genres</SidebarItem>
<YouTubeSidebarItem v-show="showYouTube" />
</ul>
</section>
<PlaylistList />
<section v-if="showManageSection" class="manage">
<h1>Manage</h1>
<ul class="menu">
<SidebarItem v-if="isAdmin" screen="Settings" href="#/settings" :icon="faTools">Settings</SidebarItem>
<SidebarItem screen="Upload" href="#/upload" :icon="faUpload">Upload</SidebarItem>
<SidebarItem v-if="isAdmin" screen="Users" href="#/users" :icon="faUsers">Users</SidebarItem>
</ul>
</section>
</section> </section>
<section v-if="!isPlus && isAdmin" class="plus-wrapper"> <section v-if="!isPlus && isAdmin" class="p-6">
<BtnUpgradeToPlus /> <BtnUpgradeToPlus />
</section> </section>
<button class="btn-toggle" @click.prevent="toggleNavbar"> <SidebarToggleButton v-model="expanded" />
<Icon v-if="collapsed" :icon="faAngleRight" />
<Icon v-else :icon="faAngleLeft" />
</button>
</nav> </nav>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { import { computed, ref, watch } from 'vue'
faCompactDisc,
faHome,
faMicrophone,
faMusic,
faTags,
faTools,
faUpload,
faUsers,
faAngleLeft,
faAngleRight
} from '@fortawesome/free-solid-svg-icons'
import { computed, ref } from 'vue'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { useAuthorization, useKoelPlus, useRouter, useThirdPartyServices, useUpload, useLocalStorage } from '@/composables' import {
useAuthorization,
useKoelPlus,
useRouter,
useUpload,
useLocalStorage
} from '@/composables'
import SidebarItem from './SidebarItem.vue' import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
import QueueSidebarItem from './QueueSidebarItem.vue'
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
import PlaylistList from './PlaylistSidebarList.vue'
import SearchForm from '@/components/ui/SearchForm.vue' import SearchForm from '@/components/ui/SearchForm.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue' import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
import SidebarYourMusicSection from './SidebarYourMusicSection.vue'
import SidebarManageSection from './SidebarManageSection.vue'
import SidebarToggleButton from '@/components/layout/main-wrapper/sidebar/SidebarToggleButton.vue'
const { onRouteChanged } = useRouter() const { onRouteChanged } = useRouter()
const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization() const { isAdmin } = useAuthorization()
const { allowsUpload } = useUpload() const { allowsUpload } = useUpload()
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()
const { get: lsGet, set: lsSet } = useLocalStorage() const { get: lsGet, set: lsSet } = useLocalStorage()
const collapsed = ref(lsGet('sidebar-collapsed', false))
const mobileShowing = ref(false) const mobileShowing = ref(false)
const youTubePlaying = ref(false) const expanded = ref(!lsGet('sidebar-collapsed', false))
watch(expanded, value => lsSet('sidebar-collapsed', !value))
const showYouTube = computed(() => useYouTube.value && youTubePlaying.value)
const showManageSection = computed(() => isAdmin.value || allowsUpload.value) const showManageSection = computed(() => isAdmin.value || allowsUpload.value)
const closeIfMobile = () => (mobileShowing.value = false) const closeIfMobile = () => (mobileShowing.value = false)
const toggleNavbar = () => {
collapsed.value = !collapsed.value
lsSet('sidebar-collapsed', collapsed.value)
}
let tmpShowingHandler: number | undefined let tmpShowingHandler: number | undefined
const tmpShowing = ref(false) const tmpShowing = ref(false)
const onMouseEnter = () => { const onMouseEnter = () => {
if (!collapsed.value) return; if (expanded.value) return;
tmpShowingHandler = window.setTimeout(() => { tmpShowingHandler = window.setTimeout(() => {
if (!collapsed.value) return if (expanded.value) return
tmpShowing.value = true tmpShowing.value = true
}, 500) }, 500)
} }
@ -127,184 +88,42 @@ onRouteChanged(_ => (mobileShowing.value = false))
* This should only be triggered on a mobile device. * This should only be triggered on a mobile device.
*/ */
eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value)) eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value))
.on('PLAY_YOUTUBE_VIDEO', _ => (youTubePlaying.value = true))
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
@import '@/../css/partials/mixins.pcss';
nav { nav {
position: relative; @apply bg-k-bg-secondary;
width: var(--sidebar-width);
background-color: var(--color-bg-secondary);
-ms-overflow-style: -ms-autohiding-scrollbar; -ms-overflow-style: -ms-autohiding-scrollbar;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
will-change: width;
&.collapsed { &.collapsed {
transition: width .2s; @apply w-[24px] transition-[width] duration-200;
width: 24px;
> *:not(.btn-toggle) { > *:not(.btn-toggle) {
display: none; @apply hidden;
} }
&.tmp-showing { &.tmp-showing {
position: absolute; @apply absolute h-screen z-50 bg-k-bg-primary w-k-sidebar-width shadow-2xl;
background-color: var(--color-bg-primary);
width: var(--sidebar-width);
height: 100vh;
z-index: 100;
> *:not(.btn-toggle) { > *:not(.btn-toggle) {
display: block; @apply block;
} }
} }
} }
form[role=search] {
min-height: 38px;
}
.search-wrapper {
padding: 1.8rem 1.5rem;
}
.menu-wrapper {
flex: 1;
padding: 0 1.5rem;
overflow-y: auto;
@media (hover: none) {
/* Enable scroll with momentum on touch devices */
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
}
.plus-wrapper {
padding: 1rem 1.5rem;
}
.menu-wrapper > * + * {
margin-top: 2.25rem;
}
.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
border-radius: 4px;
cursor: copy;
}
.queue > span {
display: flex;
align-items: baseline;
justify-content: space-between;
flex: 1;
}
:deep(h1) {
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
:deep(a svg) {
opacity: .7;
}
:deep(a) {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
&:active {
padding: 2px 0 0 2px;
}
&.active, &:hover {
color: var(--color-text-primary);
}
&::before {
content: '';
right: -1.5rem;
top: 25%;
width: 4px;
height: 50%;
position: absolute;
transition: box-shadow .5s ease-in-out, background-color .5s ease-in-out;
border-radius: 9999rem;
}
&.active {
&::before {
background-color: var(--color-highlight);
box-shadow: 0 0 40px 10px var(--color-highlight);
}
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
:deep(li li a) { /* submenu items */
padding-left: 11px;
&:active {
padding: 2px 0 0 13px;
}
}
.btn-toggle {
width: 24px;
aspect-ratio: 1 / 1;
position: absolute;
color: var(--color-text-secondary);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
right: -12px;
top: 30px;
z-index: 5;
&:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
}
@media screen and (max-width: 768px) {
display: none;
}
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
background-color: var(--color-bg-primary); @mixin themed-background;
background-image: var(--bg-image);
background-attachment: var(--bg-attachment);
background-size: var(--bg-size);
background-position: var(--bg-position);
transform: translateX(-100vw); transform: translateX(-100vw);
transition: transform .2s ease-in-out; transition: transform .2s ease-in-out;
position: fixed;
width: 100%;
z-index: 99;
height: calc(100vh - var(--header-height)); height: calc(100vh - var(--header-height));
&.showing { &.showing {
transform: translateX(0); transform: translateX(0);
} }
} }
} }
</style> </style>

View file

@ -1,8 +1,19 @@
<template> <template>
<li> <li
<a :class="active && 'active'" :href="props.href"> :class="current && 'current'"
<Icon :icon="props.icon" fixed-width /> class="relative before:-right-6 before:top-1/4 before:w-[4px] before:h-1/2 before:absolute before:rounded-full
<span> before:transition-[box-shadow,_background-color] before:ease-in-out before:duration-500"
>
<a
:href="props.href"
class="flex items-center overflow-x-hidden gap-3 h-11 relative active:pt-0.5 active:pr-0 active:pb-0 active:pl-0.5
!text-k-text-secondary hover:!text-k-text-primary"
>
<span class="opacity-70">
<slot name="icon" />
</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
<slot /> <slot />
</span> </span>
</a> </a>
@ -10,14 +21,32 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Component, ref } from 'vue' import { ref } from 'vue'
import { useRouter } from '@/composables' import { useRouter } from '@/composables'
const props = defineProps<{ href: string; icon: Component, screen: ScreenName }>() const props = withDefaults(defineProps<{ href?: string | undefined; screen?: ScreenName | undefined }>(), {
href: undefined,
screen: undefined
})
const active = ref(false) const current = ref(false)
const { onRouteChanged } = useRouter() const { onRouteChanged } = useRouter()
onRouteChanged(route => active.value = route.screen === props.screen) if (screen) {
onRouteChanged(route => current.value = route.screen === props.screen)
}
</script> </script>
<style lang="postcss" scoped>
li.current {
a {
@apply text-k-text-primary !important;
}
&::before {
@apply bg-k-highlight !important;
box-shadow: 0 0 40px 10px var(--color-highlight);
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<SidebarSection>
<template #header>
<SidebarSectionHeader>Manage</SidebarSectionHeader>
</template>
<ul class="menu">
<SidebarItem v-if="isAdmin" screen="Settings" href="#/settings">
<template #icon>
<Icon :icon="faTools" fixed-width />
</template>
Settings
</SidebarItem>
<SidebarItem screen="Upload" href="#/upload">
<template #icon>
<Icon :icon="faUpload" fixed-width />
</template>
Upload
</SidebarItem>
<SidebarItem v-if="isAdmin" screen="Users" href="#/users">
<template #icon>
<Icon :icon="faUsers" fixed-width />
</template>
Users
</SidebarItem>
</ul>
</SidebarSection>
</template>
<script setup lang="ts">
import { faTools, faUpload, faUsers } from '@fortawesome/free-solid-svg-icons'
import { useAuthorization } from '@/composables'
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
const { isAdmin } = useAuthorization()
</script>

View file

@ -3,13 +3,13 @@ import { it } from 'vitest'
import { playlistFolderStore, playlistStore } from '@/stores' import { playlistFolderStore, playlistStore } from '@/stores'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import PlaylistSidebarList from './PlaylistSidebarList.vue' import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
import PlaylistSidebarItem from './PlaylistSidebarItem.vue' import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue' import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private renderComponent () { private renderComponent () {
this.render(PlaylistSidebarList, { this.render(SidebarPlaylistsSection, {
global: { global: {
stubs: { stubs: {
PlaylistSidebarItem, PlaylistSidebarItem,

View file

@ -0,0 +1,38 @@
<template>
<SidebarSection>
<SidebarSectionHeader class="flex items-center">
<span class="flex-1">Playlists</span>
<CreatePlaylistContextMenuButton />
</SidebarSectionHeader>
<ul v-koel-overflow-fade class="max-h-96">
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }" />
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }" />
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" />
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" />
</ul>
</SidebarSection>
</template>
<script lang="ts" setup>
import { computed, toRef } from 'vue'
import { favoriteStore, playlistFolderStore, playlistStore } from '@/stores'
import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
import CreatePlaylistContextMenuButton from '@/components/playlist/CreatePlaylistContextMenuButton.vue'
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
if (folder_id === null) return true
// if the playlist's folder is not found, it's an orphan
// this can happen if the playlist belongs to another user (collaborative playlist)
return !folders.value.find(folder => folder.id === folder_id)
}))
</script>

View file

@ -0,0 +1,6 @@
<template>
<section class="space-y-4">
<slot name="header" />
<slot />
</section>
</template>

View file

@ -0,0 +1,5 @@
<template>
<h3 class="uppercase tracking-widest mb-3">
<slot />
</h3>
</template>

View file

@ -0,0 +1,24 @@
<template>
<label
class="btn-toggle hidden md:flex w-[24px] aspect-square absolute rounded-full -right-[12px] top-[27px] items-center
justify-center z-10 text-k-text-secondary bg-k-bg-secondary border-[1.5px] border-white/20 cursor-pointer
hover:text-k-text-primary hover:bg-k-bg-secondary"
>
<input v-model="value" type="checkbox" class="hidden">
<Icon v-if="value" :icon="faAngleLeft" />
<Icon v-else :icon="faAngleRight" />
</label>
</template>
<script setup lang="ts">
import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = withDefaults(defineProps<{ modelValue?: boolean }>(), { modelValue: false })
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const value = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
</script>

View file

@ -0,0 +1,62 @@
<template>
<SidebarSection>
<template #header>
<SidebarSectionHeader>Your Music</SidebarSectionHeader>
</template>
<ul class="menu">
<SidebarItem screen="Home" href="#/home">
<template #icon>
<Icon :icon="faHome" fixed-width />
</template>
Home
</SidebarItem>
<QueueSidebarItem />
<SidebarItem screen="Songs" href="#/songs">
<template #icon>
<Icon :icon="faMusic" fixed-width />
</template>
All Songs
</SidebarItem>
<SidebarItem screen="Albums" href="#/albums">
<template #icon>
<Icon :icon="faCompactDisc" fixed-width />
</template>
Albums
</SidebarItem>
<SidebarItem screen="Artists" href="#/artists">
<template #icon>
<Icon :icon="faMicrophone" fixed-width />
</template>
Artists
</SidebarItem>
<SidebarItem screen="Genres" href="#/genres">
<template #icon>
<Icon :icon="faTags" fixed-width />
</template>
Genres
</SidebarItem>
<YouTubeSidebarItem v-show="showYouTube" />
</ul>
</SidebarSection>
</template>
<script setup lang="ts">
import { faCompactDisc, faHome, faMicrophone, faMusic, faTags } from '@fortawesome/free-solid-svg-icons'
import { computed, ref } from 'vue'
import { eventBus } from '@/utils'
import { useThirdPartyServices } from '@/composables'
import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSection.vue'
import SidebarSectionHeader from '@/components/layout/main-wrapper/sidebar/SidebarSectionHeader.vue'
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
import QueueSidebarItem from '@/components/layout/main-wrapper/sidebar/QueueSidebarItem.vue'
import YouTubeSidebarItem from '@/components/layout/main-wrapper/sidebar/YouTubeSidebarItem.vue'
const { useYouTube } = useThirdPartyServices()
const youTubePlaying = ref(false)
const showYouTube = computed(() => useYouTube.value && youTubePlaying.value)
eventBus.on('PLAY_YOUTUBE_VIDEO', () => (youTubePlaying.value = true))
</script>

View file

@ -1,8 +1,14 @@
<template> <template>
<SidebarItem screen="YouTube" href="#/youtube" :icon="faYoutube">{{ title }}</SidebarItem> <SidebarItem screen="YouTube" href="#/youtube">
<template #icon>
<Icon :icon="faYoutube" fixed-width />
</template>
{{ title }}
</SidebarItem>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { unescape } from 'lodash'
import { faYoutube } from '@fortawesome/free-brands-svg-icons' import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref } from 'vue' import { ref } from 'vue'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
@ -11,5 +17,5 @@ import SidebarItem from './SidebarItem.vue'
const title = ref('') const title = ref('')
eventBus.on('PLAY_YOUTUBE_VIDEO', payload => (title.value = payload.title)) eventBus.on('PLAY_YOUTUBE_VIDEO', payload => (title.value = unescape(payload.title)))
</script> </script>

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = `<li><a class="" href="#"><br data-testid="Icon" icon="[object Object]" fixed-width=""><span>Home</span></a></li>`; exports[`renders 1`] = `<li data-v-7589e0e3="" class="tw-relative before:-tw-right-6 before:tw-top-1/4 before:tw-w-[4px] before:tw-h-1/2 before:tw-absolute before:tw-rounded-full" icon="[object Object]"><a data-v-7589e0e3="" href="#" class="tw-flex tw-items-center tw-overflow-x-hidden tw-gap-3 tw-h-11 tw-relative active:tw-pt-0.5 active:tw-pr-0 active:tw-pb-0 active:tw-pl-0.5"><span data-v-7589e0e3="" class="tw-opacity-70"></span><span data-v-7589e0e3="" class="tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap">Home</span></a></li>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = `<li><a class="" href="#/youtube"><br data-testid="Icon" icon="[object Object]" fixed-width=""><span>A Random Video</span></a></li>`; exports[`renders 1`] = `<li data-v-7589e0e3="" class="tw-relative before:-tw-right-6 before:tw-top-1/4 before:tw-w-[4px] before:tw-h-1/2 before:tw-absolute before:tw-rounded-full"><a data-v-7589e0e3="" href="#/youtube" class="tw-flex tw-items-center tw-overflow-x-hidden tw-gap-3 tw-h-11 tw-relative active:tw-pt-0.5 active:tw-pr-0 active:tw-pb-0 active:tw-pl-0.5"><span data-v-7589e0e3="" class="tw-opacity-70"><br data-testid="Icon" icon="[object Object]" fixed-width=""></span><span data-v-7589e0e3="" class="tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap"> A Random Video</span></a></li>`;

View file

@ -1,8 +1,14 @@
<template> <template>
<div v-koel-focus class="about text-secondary" data-testid="about-koel" tabindex="0" @keydown.esc="close"> <div
<main> v-koel-focus
<div class="logo"> class="about text-k-text-secondary text-center max-w-[480px] overflow-hidden relative"
<img alt="Koel's logo" src="@/../img/logo.svg" width="128"> data-testid="about-koel"
tabindex="0"
@keydown.esc="close"
>
<main class="p-6">
<div class="mb-4">
<img alt="Koel's logo" src="@/../img/logo.svg" width="128" class="inline-block">
</div> </div>
<div class="current-version"> <div class="current-version">
@ -13,13 +19,13 @@
<p v-if="isPlus" class="plus-badge"> <p v-if="isPlus" class="plus-badge">
Licensed to {{ license.customerName }} &lt;{{ license.customerEmail }}&gt; Licensed to {{ license.customerName }} &lt;{{ license.customerEmail }}&gt;
<br> <br>
License key: <span class="key">{{ license.shortKey }}</span> License key: <span class="key font-mono">{{ license.shortKey }}</span>
</p> </p>
<template v-else> <template v-else>
<p v-if="isAdmin" class="upgrade"> <p v-if="isAdmin" class="py-3">
<!-- close the modal first to prevent it from overlapping Lemonsqueezy's overlay --> <!-- close the modal first to prevent it from overlapping Lemonsqueezy's overlay -->
<BtnUpgradeToPlus @click.prevent="showPlusModal" /> <BtnUpgradeToPlus class="!w-auto inline-block !px-6" @click.prevent="showPlusModal" />
</p> </p>
</template> </template>
</div> </div>
@ -35,19 +41,11 @@
<a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a> <a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a>
and quite a few and quite a few
<a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
>contributors</a>. >contributors</a>.
</p> </p>
<div v-if="credits" class="credit-wrapper" data-testid="demo-credits"> <CreditsBlock v-if="isDemo" />
Music by
<ul class="credits">
<li v-for="credit in credits" :key="credit.name">
<a :href="credit.url" target="_blank">{{ credit.name }}</a>
</li>
</ul>
</div>
<SponsorList /> <SponsorList />
<p v-if="!isPlus"> <p v-if="!isPlus">
@ -59,28 +57,19 @@
</main> </main>
<footer> <footer>
<Btn data-testid="close-modal-btn" red rounded @click.prevent="close">Close</Btn> <Btn data-testid="close-modal-btn" danger rounded @click.prevent="close">Close</Btn>
</footer> </footer>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { orderBy } from 'lodash'
import { onMounted, ref } from 'vue'
import { useAuthorization, useKoelPlus, useNewVersionNotification } from '@/composables' import { useAuthorization, useKoelPlus, useNewVersionNotification } from '@/composables'
import { http } from '@/services'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import SponsorList from '@/components/meta/SponsorList.vue' import SponsorList from '@/components/meta/SponsorList.vue'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue' import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
import CreditsBlock from '@/components/meta/CreditsBlock.vue'
type DemoCredits = {
name: string
url: string
}
const credits = ref<DemoCredits[] | null>(null)
const { const {
shouldNotifyNewVersion, shouldNotifyNewVersion,
@ -100,87 +89,23 @@ const showPlusModal = () => {
eventBus.emit('MODAL_SHOW_KOEL_PLUS') eventBus.emit('MODAL_SHOW_KOEL_PLUS')
} }
onMounted(async () => { const isDemo = window.IS_DEMO;
credits.value = window.IS_DEMO ? orderBy(await http.get<DemoCredits[]>('demo/credits'), 'name') : null
})
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.about { p {
text-align: center; @apply mx-0 my-3;
max-width: 480px;
overflow: hidden;
position: relative;
main {
padding: 1.8rem;
p {
margin: 1rem 0;
}
}
footer {
padding: 1rem;
background: rgba(255, 255, 255, .02);
}
a {
color: var(--color-text-primary);
&:hover {
color: var(--color-accent);
}
}
} }
.credit-wrapper { a {
max-height: 9rem; @apply text-k-text-primary hover:text-k-accent;
overflow: auto;
}
.credits, .credits li {
display: inline;
}
.credits {
display: inline;
li {
display: inline;
&:last-child {
&::before {
content: ', and '
}
&::after {
content: '.';
}
}
}
li + li {
&::before {
content: ', ';
}
}
}
.sponsors {
margin-top: 1rem;
} }
.plus-badge { .plus-badge {
.key { .key {
font-family: monospace;
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-image: linear-gradient(97.78deg, #c62be8 17.5%, #671ce4 113.39%); background-image: linear-gradient(97.78deg, #c62be8 17.5%, #671ce4 113.39%);
} }
} }
.upgrade {
padding: .5rem 0;
}
</style> </style>

View file

@ -0,0 +1,45 @@
<template>
<div v-koel-overflow-fade class="max-h-[9rem] overflow-auto" data-testid="demo-credits">
Music by
<ul class="inline">
<li v-for="credit in credits" :key="credit.name" class="inline">
<a :href="credit.url" target="_blank">{{ credit.name }}</a>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { orderBy } from 'lodash'
import { onMounted, ref } from 'vue'
import { http } from '@/services'
type DemoCredits = {
name: string
url: string
}
const credits = ref<DemoCredits[]>([])
onMounted(async () => {
credits.value = window.IS_DEMO ? orderBy(await http.get<DemoCredits[]>('demo/credits'), 'name') : []
})
</script>
<style scoped lang="postcss">
li&:last-child {
&::before {
content: ', and '
}
&::after {
content: '.';
}
}
li + li {
&::before {
content: ', ';
}
}
</style>

View file

@ -1,36 +1,26 @@
<template> <template>
<div class="sponsors"> <div
class="flex items-center flex-wrap justify-center gap-x-2 gap-y-4 p-4 bg-black/10
rounded-md border border-white/5"
>
<a <a
v-for="sponsor in sponsors" v-for="sponsor in sponsors"
:key="sponsor.url" :key="sponsor.url"
:href="sponsor.url" :href="sponsor.url"
:title="sponsor.description" :title="sponsor.description"
class="opacity-70 hover:opacity-100"
target="_blank" target="_blank"
> >
<img :alt="sponsor.description" :src="sponsor.logo.src" :style="sponsor.logo.style"> <img
:alt="sponsor.description"
:src="sponsor.logo.src"
:style="sponsor.logo.style"
class="brightness-[10] h-[32px]"
>
</a> </a>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import sponsors from '@/sponsors'</script> import sponsors from '@/sponsors'
</script>
<style lang="postcss" scoped>
.sponsors {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
row-gap: 4px;
column-gap: 16px;
padding: 8px;
background: rgba(0, 0, 0, .1);
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, .05);
img {
filter: brightness(10);
height: 32px;
}
}
</style>

View file

@ -1,13 +1,17 @@
<template> <template>
<div v-if="shown" class="support-bar" data-testid="support-bar"> <div
<p> v-if="shown"
class="bg-k-bg-primary text-[0.9rem] px-6 py-4 flex text-k-text-secondary z-10 space-x-3"
data-testid="support-bar"
>
<p class="flex-1">
Loving Koel? Please consider supporting its development via Loving Koel? Please consider supporting its development via
<a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a> <a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a>
and/or and/or
<a href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>. <a href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>.
</p> </p>
<button type="button" @click.prevent="close">Hide</button> <button type="button" @click.prevent="close">Hide</button>
<span class="sep" /> <span class="block after:content-['•'] after:block" />
<button type="button" @click.prevent="stopBugging"> <button type="button" @click.prevent="stopBugging">
Don't bug me again Don't bug me again
</button> </button>
@ -43,46 +47,11 @@ watch(preferenceStore.initialized, initialized => {
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.support-bar { a {
background: var(--color-bg-primary); @apply text-k-text-primary hover:text-k-accent;
font-size: .9rem; }
padding: .75rem 1rem;
display: flex;
color: rgba(255, 255, 255, .6);
z-index: 9;
> * + * { button {
margin-left: 1rem; @apply text-k-text-primary text-[0.9rem] hover:text-k-accent;
}
p {
flex: 1;
}
a {
color: var(--color-text-primary);
&:hover {
color: var(--color-highlight);
}
}
.sep {
display: block;
&::after {
content: '•';
display: block;
}
}
button {
color: var(--color-text-primary);
font-size: .9rem;
&:hover {
color: var(--color-highlight);
}
}
} }
</style> </style>

View file

@ -12,6 +12,6 @@ exports[`renders 1`] = `
<!--v-if--><br data-v-6b5b01a9="" data-testid="sponsor-list"> <!--v-if--><br data-v-6b5b01a9="" data-testid="sponsor-list">
<p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a data-v-6b5b01a9="" href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a> and/or <a data-v-6b5b01a9="" href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>. </p> <p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a data-v-6b5b01a9="" href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a> and/or <a data-v-6b5b01a9="" href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>. </p>
</main> </main>
<footer data-v-6b5b01a9=""><button data-v-e368fe26="" data-v-6b5b01a9="" class="inset-when-pressed" data-testid="close-modal-btn" red="" rounded="">Close</button></footer> <footer data-v-6b5b01a9=""><button data-v-8943c846="" data-v-6b5b01a9="" class="inset-when-pressed tw-text-base tw-px-4 tw-py-2 tw-rounded tw-cursor-pointer" data-testid="close-modal-btn" red="" rounded="">Close</button></footer>
</div> </div>
`; `;

View file

@ -3,7 +3,8 @@ import { screen, waitFor } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { Events } from '@/config' import { Events } from '@/config'
import CreateNewPlaylistContextMenu from './CreateNewPlaylistContextMenu.vue'
import CreateNewPlaylistContextMenu from './CreatePlaylistContextMenu.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private async renderComponent () { private async renderComponent () {

View file

@ -0,0 +1,24 @@
<template>
<button
class="relative before:absolute before:w-[28px] before:aspect-square before:top-[-6px] before:left-[-6px] before:cursor-pointer"
title="Create a new playlist or folder"
type="button"
@click.stop.prevent="requestContextMenu"
>
<Icon :icon="faCirclePlus" />
</button>
</template>
<script setup lang="ts">
import { faCirclePlus } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'
const requestContextMenu = (e: MouseEvent) => {
const { bottom, right } = (e.currentTarget as HTMLButtonElement).getBoundingClientRect()
eventBus.emit('CREATE_NEW_PLAYLIST_CONTEXT_MENU_REQUESTED', {
top: bottom,
left: right
})
}
</script>

View file

@ -5,16 +5,15 @@
</header> </header>
<main> <main>
<div class="form-row"> <FormRow>
<input <TextInput
v-model="name" v-model="name"
v-koel-focus v-koel-focus
name="name" name="name"
placeholder="Folder name" placeholder="Folder name"
required required
type="text" />
> </FormRow>
</div>
</main> </main>
<footer> <footer>
@ -30,7 +29,9 @@ import { playlistFolderStore } from '@/stores'
import { logger } from '@/utils' import { logger } from '@/utils'
import { useDialogBox, useMessageToaster, useOverlay } from '@/composables' import { useDialogBox, useMessageToaster, useOverlay } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()

View file

@ -1,39 +1,28 @@
<template> <template>
<form @submit.prevent="submit" @keydown.esc="maybeClose"> <form class="min-w-full" @submit.prevent="submit" @keydown.esc="maybeClose">
<header> <header>
<h1> <h1>
New Playlist New Playlist
<span <span v-if="songs.length" data-testid="from-songs" class="text-k-text-secondary">
v-if="songs.length"
data-testid="from-songs"
class="text-secondary"
>
from {{ pluralize(songs, 'song') }} from {{ pluralize(songs, 'song') }}
</span> </span>
</h1> </h1>
</header> </header>
<main> <main>
<div class="form-row cols"> <FormRow :cols="2">
<label class="name"> <FormRow>
Name <template #label>Name</template>
<input <TextInput v-model="name" v-koel-focus name="name" placeholder="Playlist name" required />
v-model="name" </FormRow>
v-koel-focus <FormRow>
name="name" <template #label>Folder</template>
placeholder="Playlist name" <SelectBox v-model="folderId">
required
type="text"
>
</label>
<label class="folder">
Folder
<select v-model="folderId">
<option :value="null" /> <option :value="null" />
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option> <option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select> </SelectBox>
</label> </FormRow>
</div> </FormRow>
</main> </main>
<footer> <footer>
@ -49,7 +38,10 @@ import { playlistFolderStore, playlistStore } from '@/stores'
import { logger, pluralize } from '@/utils' import { logger, pluralize } from '@/utils'
import { useDialogBox, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables' import { useDialogBox, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()
@ -97,13 +89,3 @@ const maybeClose = async () => {
await showConfirmDialog('Discard all changes?') && close() await showConfirmDialog('Discard all changes?') && close()
} }
</script> </script>
<style lang="postcss" scoped>
form {
min-width: 100%;
}
label.folder {
flex: .6;
}
</style>

View file

@ -5,17 +5,9 @@
</header> </header>
<main> <main>
<div class="form-row"> <FormRow>
<input <TextInput v-model="name" v-koel-focus name="name" placeholder="Folder name" required title="Folder name" />
v-model="name" </FormRow>
v-koel-focus
name="name"
placeholder="Folder name"
required
title="Folder name"
type="text"
>
</div>
</main> </main>
<footer> <footer>
@ -31,7 +23,9 @@ import { logger } from '@/utils'
import { playlistFolderStore } from '@/stores' import { playlistFolderStore } from '@/stores'
import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composables' import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()

View file

@ -5,27 +5,26 @@
</header> </header>
<main> <main>
<div class="form-row cols"> <FormRow :cols="2">
<label class="name"> <FormRow>
Name <template #label>Name</template>
<input <TextInput
v-model="name" v-model="name"
v-koel-focus v-koel-focus
name="name" name="name"
placeholder="Playlist name" placeholder="Playlist name"
required required
title="Playlist name" title="Playlist name"
type="text" />
> </FormRow>
</label> <FormRow>
<label class="folder"> <template #label>Folder</template>
Folder <SelectBox v-model="folderId">
<select v-model="folderId">
<option :value="null" /> <option :value="null" />
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option> <option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select> </SelectBox>
</label> </FormRow>
</div> </FormRow>
</main> </main>
<footer> <footer>
@ -41,7 +40,10 @@ import { logger } from '@/utils'
import { playlistFolderStore, playlistStore } from '@/stores' import { playlistFolderStore, playlistStore } from '@/stores'
import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composables' import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import TextInput from '@/components/ui/form/TextInput.vue'
import FormRow from '@/components/ui/form/FormRow.vue'
import SelectBox from '@/components/ui/form/SelectBox.vue'
const { showOverlay, hideOverlay } = useOverlay() const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster() const { toastSuccess } = useMessageToaster()

View file

@ -1,11 +1,11 @@
<template> <template>
<span> <span>
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn> <Btn v-if="shouldShowInviteButton" success small @click.prevent="inviteCollaborators">Invite</Btn>
<span v-if="justCreatedInviteLink" class="text-secondary copied"> <span v-if="justCreatedInviteLink" class="text-k-text-secondary text-[0.95rem]">
<Icon :icon="faCheckCircle" class="text-green" /> <Icon :icon="faCheckCircle" class="text-k-success mr-1" />
Link copied to clipboard! Link copied to clipboard!
</span> </span>
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin /> <Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-k-success" spin />
</span> </span>
</template> </template>
@ -15,7 +15,7 @@ import { computed, ref, toRefs } from 'vue'
import { copyText } from '@/utils' import { copyText } from '@/utils'
import { playlistCollaborationService } from '@/services' import { playlistCollaborationService } from '@/services'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
const props = defineProps<{ playlist: Playlist }>() const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props) const { playlist } = toRefs(props)
@ -36,13 +36,3 @@ const inviteCollaborators = async () => {
} }
} }
</script> </script>
<style scoped lang="postcss">
.copied {
font-size: .95rem;
}
svg {
margin-right: .25rem;
}
</style>

View file

@ -1,22 +1,22 @@
<template> <template>
<div class="collaboration-modal" tabindex="0" @keydown.esc="close"> <div class="collaboration-modal max-w-[640px]" tabindex="0" @keydown.esc="close">
<header> <header>
<h1>Playlist Collaboration</h1> <h1>Playlist Collaboration</h1>
</header> </header>
<main> <main>
<p class="intro text-secondary"> <p class="text-k-text-secondary">
Collaborative playlists allow multiple users to contribute. <br> Collaborative playlists allow multiple users to contribute. <br>
Note: Songs added to a collaborative playlist are made accessible to all users, Note: Songs added to a collaborative playlist are made accessible to all users,
and you cannot mark a song as private if its still part of a collaborative playlist. and you cannot mark a song as private if its still part of a collaborative playlist.
</p> </p>
<section class="collaborators"> <section class="space-y-5">
<h2> <h2 class="flex text-xl mt-6 mb-1 items-center">
<span>Current Collaborators</span> <span class="flex-1">Current Collaborators</span>
<InviteCollaborators v-if="canManageCollaborators" :playlist="playlist" /> <InviteCollaborators v-if="canManageCollaborators" :playlist="playlist" />
</h2> </h2>
<div v-koel-overflow-fade class="collaborators-wrapper"> <div v-koel-overflow-fade class="collaborators-wrapper overflow-auto">
<CollaboratorList :playlist="playlist" /> <CollaboratorList :playlist="playlist" />
</div> </div>
</section> </section>
@ -29,18 +29,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, Ref } from 'vue' import { computed } from 'vue'
import { useAuthorization, useModal, useDialogBox } from '@/composables' import { useAuthorization, useModal } from '@/composables'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue' import InviteCollaborators from '@/components/playlist/InvitePlaylistCollaborators.vue'
import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue' import CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
const playlist = useModal().getFromContext<Playlist>('playlist') const playlist = useModal().getFromContext<Playlist>('playlist')
const { currentUser } = useAuthorization() const { currentUser } = useAuthorization()
const { showConfirmDialog } = useDialogBox()
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id) const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
@ -49,22 +46,7 @@ const close = () => emit('close')
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.collaboration-modal {
max-width: 640px;
}
h2 {
display: flex;
font-size: 1.2rem;
margin: 1.5rem 0;
span:first-child {
flex: 1;
}
}
.collaborators-wrapper { .collaborators-wrapper {
max-height: calc(100vh - 8rem); max-height: calc(100vh - 8rem);
overflow-y: auto;
} }
</style> </style>

View file

@ -1,13 +1,13 @@
<template> <template>
<ListSkeleton v-if="loading" /> <ListSkeleton v-if="loading" />
<ul v-else> <ul v-else class="w-full space-y-3">
<ListItem <ListItem
is="li"
v-for="collaborator in collaborators" v-for="collaborator in collaborators"
:role="collaborator.id === playlist.user_id ? 'owner' : 'contributor'" :key="collaborator.id"
:collaborator="collaborator"
:manageable="currentUserIsOwner" :manageable="currentUserIsOwner"
:removable="currentUserIsOwner && collaborator.id !== playlist.user_id" :removable="currentUserIsOwner && collaborator.id !== playlist.user_id"
:collaborator="collaborator" :role="collaborator.id === playlist.user_id ? 'owner' : 'contributor'"
@remove="removeCollaborator(collaborator)" @remove="removeCollaborator(collaborator)"
/> />
</ul> </ul>
@ -69,13 +69,3 @@ const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
onMounted(async () => await fetchCollaborators()) onMounted(async () => await fetchCollaborators())
</script> </script>
<style scoped lang="postcss">
ul {
display: flex;
width: 100%;
flex-direction: column;
margin: 1rem 0;
gap: .5rem;
}
</style>

View file

@ -1,23 +1,26 @@
<template> <template>
<li> <li
class="flex items-center justify-center w-full gap-3 py-2 px-3 rounded-md transition-colors duration-200 ease-in-out
bg-k-bg-secondary border border-k-border hover:border hover:border-white/15"
>
<span class="avatar"> <span class="avatar">
<UserAvatar :user="collaborator" width="32" /> <UserAvatar :user="collaborator" width="32" />
</span> </span>
<span class="name"> <span class="flex-1">
{{ collaborator.name }} {{ collaborator.name }}
<Icon <Icon
v-if="collaborator.id === currentUser.id" v-if="collaborator.id === currentUser.id"
:icon="faCircleCheck" :icon="faCircleCheck"
class="you text-highlight" class="text-k-highlight ml-1"
title="This is you!" title="This is you!"
/> />
</span> </span>
<span class="role text-secondary"> <span class="role text-k-text-secondary text-right flex-[0_0_104px] uppercase">
<span v-if="role === 'owner'" class="owner">Owner</span> <span v-if="role === 'owner'" class="owner">Owner</span>
<span v-else class="contributor">Contributor</span> <span v-else class="contributor">Contributor</span>
</span> </span>
<span v-if="manageable" class="actions"> <span v-if="manageable" class="actions flex-[0_0_72px] text-right">
<Btn v-if="removable" small red @click.prevent="emit('remove')">Remove</Btn> <Btn v-if="removable" small danger @click.prevent="emit('remove')">Remove</Btn>
</span> </span>
</li> </li>
</template> </template>
@ -26,7 +29,7 @@
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons' import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { toRefs } from 'vue' import { toRefs } from 'vue'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import UserAvatar from '@/components/user/UserAvatar.vue' import UserAvatar from '@/components/user/UserAvatar.vue'
import { useAuthorization } from '@/composables' import { useAuthorization } from '@/composables'
@ -44,57 +47,15 @@ const emit = defineEmits<{ (e: 'remove'): void }>()
</script> </script>
<style scoped lang="postcss"> <style scoped lang="postcss">
li { span {
display: flex; @apply inline-block min-w-0 leading-normal;
align-items: center; }
justify-content: center;
width: 100%;
gap: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-bg-secondary);
padding: .5rem .8rem;
border-radius: 5px;
transition: border-color .2s ease-in-out;
&:hover { .role span {
border-color: rgba(255, 255, 255, .15); @apply px-2 py-1 rounded-md border border-white/20;
} }
.you { &:only-child .actions:not(:has(button)) {
margin-left: .5rem; @apply hidden;
}
span {
display: inline-block;
min-width: 0;
line-height: 1;
}
.name {
flex: 1;
}
.role {
text-align: right;
flex: 0 0 104px;
text-transform: uppercase;
span {
padding: 3px 4px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, .2);
}
}
.actions {
flex: 0 0 72px;
text-align: right;
}
&:only-child {
.actions:not(:has(button)) {
display: none;
}
}
} }
</style> </style>

View file

@ -1,11 +1,11 @@
<template> <template>
<div> <div class="inline-block align-middle">
<ul> <ul class="align-middle -space-x-2">
<li v-for="user in displayedCollaborators" :key="user.id"> <li v-for="user in displayedCollaborators" :key="user.id" class="inline-block align-baseline">
<UserAvatar :user="user" width="24" /> <UserAvatar :user="user" width="24" class="border border-white/30" />
</li> </li>
</ul> </ul>
<span v-if="remainderCount" class="more"> <span v-if="remainderCount" class="ml-2">
+{{ remainderCount }} more +{{ remainderCount }} more
</span> </span>
</div> </div>
@ -22,28 +22,3 @@ const { collaborators } = toRefs(props)
const displayedCollaborators = computed(() => collaborators.value.slice(0, 3)) const displayedCollaborators = computed(() => collaborators.value.slice(0, 3))
const remainderCount = computed(() => collaborators.value.length - displayedCollaborators.value.length) const remainderCount = computed(() => collaborators.value.length - displayedCollaborators.value.length)
</script> </script>
<style scoped lang="postcss">
div {
display: inline-block;
vertical-align: middle;
}
ul {
display: inline-block;
vertical-align: middle;
}
li {
display: inline-block;
vertical-align: middle;
}
li + li {
margin-left: -.3rem;
}
.more {
margin-left: .3rem;
}
</style>

View file

@ -8,27 +8,27 @@
<li @click="showCollaborationModal">Collaborate</li> <li @click="showCollaborationModal">Collaborate</li>
<li class="separator" /> <li class="separator" />
</template> </template>
<li v-if="ownedByCurrentUser" @click="edit">Edit</li> <li v-if="canEditPlaylist" @click="edit">Edit</li>
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li> <li v-if="canEditPlaylist" @click="destroy">Delete</li>
</ContextMenuBase> </ContextMenuBase>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { copyText, eventBus } from '@/utils' import { eventBus } from '@/utils'
import { useAuthorization, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables' import { usePolicies, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
import { playbackService, playlistCollaborationService } from '@/services' import { playbackService } from '@/services'
import { songStore, queueStore } from '@/stores' import { songStore, queueStore } from '@/stores'
const { base, ContextMenuBase, open, trigger } = useContextMenu() const { base, ContextMenuBase, open, trigger } = useContextMenu()
const { go } = useRouter() const { go } = useRouter()
const { toastWarning, toastSuccess } = useMessageToaster() const { toastWarning, toastSuccess } = useMessageToaster()
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()
const { currentUser } = useAuthorization() const { currentUserCan } = usePolicies()
const playlist = ref<Playlist>() const playlist = ref<Playlist>()
const ownedByCurrentUser = computed(() => playlist.value?.user_id === currentUser.value?.id) const canEditPlaylist = computed(() => currentUserCan.editPlaylist(playlist.value!))
const canShowCollaboration = computed(() => isPlus.value && !playlist.value?.is_smart) const canShowCollaboration = computed(() => isPlus.value && !playlist.value?.is_smart)
const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!)) const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!))

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