mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(ui): use Tailwind CSS
This commit is contained in:
parent
6deb76f66e
commit
bf9d9b6121
296 changed files with 5204 additions and 7669 deletions
|
@ -190,6 +190,7 @@ class SongRepository extends Repository
|
|||
'collaborators.id as collaborator_id',
|
||||
'collaborators.name as collaborator_name',
|
||||
'collaborators.email as collaborator_email',
|
||||
'collaborators.avatar as collaborator_avatar',
|
||||
'playlist_song.created_at as added_at'
|
||||
);
|
||||
})
|
||||
|
|
|
@ -17,11 +17,12 @@ abstract class CloudStorage extends SongStorage
|
|||
{
|
||||
public function __construct(protected FileScanner $scanner)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
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.
|
||||
// Instead, we copy the file to the tmp directory and scan it from there.
|
||||
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
|
||||
|
@ -42,6 +43,8 @@ abstract class CloudStorage extends SongStorage
|
|||
|
||||
public function copyToLocal(Song $song): string
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
|
||||
File::ensureDirectoryExists($tmpDir);
|
||||
|
||||
|
|
|
@ -27,6 +27,8 @@ final class DropboxStorage extends CloudStorage
|
|||
|
||||
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
return DB::transaction(function () use ($file, $uploader): Song {
|
||||
$result = $this->scanUploadedFile($file, $uploader);
|
||||
$song = $this->scanner->getSong();
|
||||
|
@ -71,16 +73,20 @@ final class DropboxStorage extends CloudStorage
|
|||
|
||||
public function getSongPresignedUrl(Song $song): string
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
return $this->filesystem->temporaryUrl($song->storage_metadata->getPath());
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
protected function supported(): bool
|
||||
{
|
||||
return SongStorageTypes::supported(SongStorageTypes::DROPBOX);
|
||||
}
|
||||
|
||||
public function delete(Song $song, bool $backup = false): void
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
$path = $song->storage_metadata->getPath();
|
||||
|
||||
if ($backup) {
|
||||
|
|
|
@ -21,11 +21,12 @@ final class LocalStorage extends SongStorage
|
|||
{
|
||||
public function __construct(private FileScanner $scanner)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
$uploadDirectory = $this->getUploadDirectory($uploader);
|
||||
$targetFileName = $this->getTargetFileName($file, $uploader);
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ class S3CompatibleStorage extends CloudStorage
|
|||
|
||||
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
return DB::transaction(function () use ($file, $uploader): Song {
|
||||
$result = $this->scanUploadedFile($file, $uploader);
|
||||
$song = $this->scanner->getSong();
|
||||
|
@ -40,11 +42,15 @@ class S3CompatibleStorage extends CloudStorage
|
|||
|
||||
public function getSongPresignedUrl(Song $song): string
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
return Storage::disk('s3')->temporaryUrl($song->storage_metadata->getPath(), now()->addHour());
|
||||
}
|
||||
|
||||
public function delete(Song $song, bool $backup = false): void
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
$disk = Storage::disk('s3');
|
||||
$path = $song->storage_metadata->getPath();
|
||||
|
||||
|
@ -61,7 +67,7 @@ class S3CompatibleStorage extends CloudStorage
|
|||
Storage::disk('s3')->delete('test.txt');
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
protected function supported(): bool
|
||||
{
|
||||
return SongStorageTypes::supported(SongStorageTypes::S3);
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
|||
|
||||
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
|
||||
{
|
||||
self::assertSupported();
|
||||
|
||||
throw new MethodNotImplementedException('Lambda storage does not support uploading.');
|
||||
}
|
||||
|
||||
|
@ -85,7 +87,7 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
|||
$song->delete();
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
protected function supported(): bool
|
||||
{
|
||||
return SongStorageTypes::supported(SongStorageTypes::S3_LAMBDA);
|
||||
}
|
||||
|
|
|
@ -9,17 +9,17 @@ use Illuminate\Http\UploadedFile;
|
|||
|
||||
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(
|
||||
$this->supported(),
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ use App\Http\Integrations\YouTube\Requests\SearchVideosRequest;
|
|||
use App\Http\Integrations\YouTube\YouTubeConnector;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Cache\Repository as Cache;
|
||||
use Throwable;
|
||||
|
||||
class YouTubeService
|
||||
{
|
||||
|
@ -27,10 +28,14 @@ class YouTubeService
|
|||
$request = new SearchVideosRequest($song, $pageToken);
|
||||
$hash = md5(serialize($request->query()->all()));
|
||||
|
||||
return $this->cache->remember(
|
||||
"youtube.$hash",
|
||||
now()->addWeek(),
|
||||
fn () => $this->connector->send($request)->object()
|
||||
);
|
||||
try {
|
||||
return $this->cache->remember(
|
||||
"youtube.$hash",
|
||||
now()->addWeek(),
|
||||
fn () => $this->connector->send($request)->object()
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ final class PlaylistCollaborator implements Arrayable
|
|||
|
||||
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> */
|
||||
|
|
|
@ -74,9 +74,11 @@
|
|||
"lint-staged": "^10.3.0",
|
||||
"lucide-vue-next": "^0.363.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-mixins": "^10.0.0",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"qrcode": "^1",
|
||||
"start-server-and-test": "^2.0.3",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.8.4",
|
||||
"vite": "^5.1.6",
|
||||
"vitepress": "^1.0.0-rc.45",
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('postcss-mixins'),
|
||||
require('postcss-nested'),
|
||||
require('autoprefixer')
|
||||
]
|
||||
|
|
|
@ -1,203 +1,41 @@
|
|||
@import './vendor/reset.pcss';
|
||||
@import './partials/vars.pcss';
|
||||
@import './partials/hack.pcss';
|
||||
|
||||
@import './partials/mixins.pcss';
|
||||
|
||||
@import '@modules/nouislider/distribute/nouislider.min.css';
|
||||
@import './vendor/plyr.pcss';
|
||||
@import './vendor/nprogress.pcss';
|
||||
|
||||
@import './partials/skeleton.pcss';
|
||||
@import './partials/tooltip.pcss';
|
||||
@import './partials/context-menu.pcss';
|
||||
@import './partials/shared.pcss';
|
||||
|
||||
.vertical-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.artist-album-wrapper {
|
||||
display: grid !important;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
@layer utilities {
|
||||
.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));
|
||||
}
|
||||
|
||||
&.as-list {
|
||||
gap: 0.7em 1em;
|
||||
align-content: start;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
.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));
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 667px) {
|
||||
display: block;
|
||||
.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;
|
||||
|
||||
>*+* {
|
||||
margin-top: .7rem;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
|
26
resources/assets/css/partials/context-menu.pcss
Normal file
26
resources/assets/css/partials/context-menu.pcss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -6,9 +6,7 @@
|
|||
* Make elements draggable in old WebKit
|
||||
*/
|
||||
[draggable] {
|
||||
user-select: none;
|
||||
-khtml-user-drag: element;
|
||||
-webkit-user-drag: element;
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,44 +14,38 @@
|
|||
*/
|
||||
html.non-mac {
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
@apply w-[10px] h-[10px];
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
@apply w-0 h-0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
border-radius: 50px;
|
||||
@apply bg-k-bg-primary border border-white/20 rounded-[50px];
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #303030;
|
||||
@apply bg-[#303030];
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: var(--color-bg-primary);
|
||||
@apply bg-k-bg-primary;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-primary);
|
||||
border: 0px none var(--color-text-primary);
|
||||
border-radius: 50px;
|
||||
@apply bg-k-bg-primary border-0 rounded-[50px];
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background: var(--color-bg-primary);
|
||||
@apply bg-k-bg-primary;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:active {
|
||||
background: #333333;
|
||||
@apply bg-[#333];
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
|
7
resources/assets/css/partials/mixins.pcss
Normal file
7
resources/assets/css/partials/mixins.pcss
Normal 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);
|
||||
}
|
|
@ -1,416 +1,54 @@
|
|||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
@tailwind base;
|
||||
|
||||
h1, h2, h3, h4, h5, h6, blockquote {
|
||||
text-wrap: 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;
|
||||
@layer base {
|
||||
h1, h2, h3, h4, h5, h6, blockquote {
|
||||
@apply text-balance;
|
||||
}
|
||||
|
||||
&[type="search"] {
|
||||
border-radius: 12px;
|
||||
height: 24px;
|
||||
padding: 0 .5rem;
|
||||
*::marker {
|
||||
@apply hidden !important;
|
||||
}
|
||||
|
||||
&[type="text"] {
|
||||
display: block;
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
&[disabled], &[readonly] {
|
||||
background: rgba(255, 255, 255, .7);
|
||||
cursor: not-allowed;
|
||||
::placeholder {
|
||||
@apply text-black/5;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
body, html {
|
||||
@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 {
|
||||
border: 0 !important;
|
||||
}
|
||||
}
|
||||
input, select, button, textarea, .btn {
|
||||
font-family: var(--font-family);
|
||||
|
||||
button,
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
@apply appearance-none border-0 outline-0 text-base font-light;
|
||||
|
||||
select {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAA4UlEQVRYhe3WMQ6CMABA0Y9xcHDVlBM5OXsC4xXcvImzg/EQTNykHZx0JcHFxkKQtpQGhv5OtCG8pElLVlMzdYupAZAQvxJClxC6WSCW5kMu8jNwAVYRvlUBV6nkqb2QmSdmLvI3sI4AMNtIJZ/mRHs77pEBRRvQhTgCj0iAEth3LTQQUskKOESAlMBOKvmyIiJBegGdiJEhVsBfxEgQJ0AvIhDiDLAiBkK8AE4IT4g3wBnhCBkE8EJYIIMB3ogW5PadKkIAANQBQwixDXlfj8YtOlWz+KlJCF1C6BJCNwvEB8RnttABpb3tAAAAAElFTkSuQmCC);
|
||||
background-size: 12px;
|
||||
background-position: calc(100% - 8px) 50%;
|
||||
padding-right: 26px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
&:required, &:invalid {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !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;
|
||||
}
|
||||
&::-moz-focus-inner {
|
||||
@apply border-0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.panes {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
a {
|
||||
@apply no-underline text-k-text-primary cursor-pointer hover:text-k-accent focus:text-k-accent;
|
||||
|
||||
.form-row + .form-row {
|
||||
margin-top: 1.125rem;
|
||||
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;
|
||||
&:link, &:visited {
|
||||
@apply text-k-accent;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
.skeleton {
|
||||
.pulse, &.pulse {
|
||||
@apply bg-white/5;
|
||||
animation: skeleton-pulse 2s infinite;
|
||||
background-color: rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
@apply opacity-0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,36 +1,12 @@
|
|||
.tooltip {
|
||||
opacity: 0;
|
||||
color: rgba(255, 255, 255, .8);
|
||||
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;
|
||||
@apply opacity-0 text-white/80 w-max absolute top-0 left-0 bg-black px-3 py-2 rounded-md pointer-events-none
|
||||
drop-shadow z-[9999] no-hover:hidden;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transition: opacity .5s ease-in-out;
|
||||
transition-delay: .3s;
|
||||
@apply opacity-100 transition-opacity duration-500 ease-in-out;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
position: absolute;
|
||||
background: #111;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
display: none !important;
|
||||
@apply absolute bg-black w-[8px] aspect-square rotate-45;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
--color-text-primary: #fff;
|
||||
--color-text-secondary: rgba(255, 255, 255, .7);
|
||||
--color-bg-primary: #181818;
|
||||
--color-bg-secondary: rgba(255, 255, 255, .025);
|
||||
--color-bg-secondary: #1d1d1d;
|
||||
--color-border: var(--color-bg-secondary);
|
||||
--color-highlight: #ff7d2e;
|
||||
--color-accent: var(--color-highlight);
|
||||
--color-bg-context-menu: var(--color-bg-primary);
|
||||
|
||||
--color-input: #333;
|
||||
--color-text-input: #333;
|
||||
--color-bg-input: #fff;
|
||||
|
||||
--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-weight-thin: 100;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 500;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--header-height: auto;
|
||||
--footer-height: 84px;
|
||||
--extra-drawer-width: 320px;
|
||||
--sidebar-width: 256px;
|
||||
--extra-drawer-width: 25rem;
|
||||
--sidebar-width: 20rem;
|
||||
|
||||
--color-black: #181818;
|
||||
--color-maroon: #bf2043;
|
||||
--color-green: #56a052;
|
||||
--color-blue: #0191f7;
|
||||
--color-red: #c34848;
|
||||
|
||||
--border-radius-input: .3rem;
|
||||
--color-love: #bf2043;
|
||||
--color-success: #56a052;
|
||||
--color-primary: #0191f7;
|
||||
--color-danger: #c34848;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
--header-height: 56px;
|
||||
|
@ -42,5 +34,3 @@
|
|||
--extra-drawer-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
$plyr-blue: var(--color-highlight);
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
@import './vendor/reset.pcss';
|
||||
@import '@modules/nouislider/distribute/nouislider.min.css';
|
||||
@import './partials/vars.pcss';
|
||||
@import './partials/mixins.pcss';
|
||||
@import './partials/shared.pcss';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body, html {
|
||||
@apply h-screen relative;
|
||||
}
|
||||
}
|
||||
|
|
21
resources/assets/css/vendor/alertify.pcss
vendored
21
resources/assets/css/vendor/alertify.pcss
vendored
|
@ -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;
|
||||
}
|
||||
}
|
32
resources/assets/css/vendor/nprogress.pcss
vendored
32
resources/assets/css/vendor/nprogress.pcss
vendored
|
@ -4,44 +4,22 @@
|
|||
* > The included CSS file is pretty minimal... in fact, feel free to scrap it and make your own!
|
||||
*/
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
@apply pointer-events-none;
|
||||
|
||||
.bar {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
.peg {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
top: 15px;
|
||||
right: 13px;
|
||||
@apply block fixed z-[9999] top-[15px] right-[13px];
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
width: 18px;
|
||||
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);
|
||||
@apply w-[18px] aspect-square border-2 border-transparent border-t-k-highlight border-l-k-highlight rounded-full animate-spin;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,13 @@
|
|||
<GlobalEventListeners />
|
||||
<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 />
|
||||
<MainWrapper />
|
||||
<AppFooter />
|
||||
|
@ -17,7 +23,7 @@
|
|||
<PlaylistFolderContextMenu />
|
||||
<CreateNewPlaylistContextMenu />
|
||||
<DropZone v-show="showDropZone" @close="showDropZone = false" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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 { authService, socketListener, socketService, uploadService } from '@/services'
|
||||
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, OverlayKey } from '@/symbols'
|
||||
import { useRouter } from '@/composables'
|
||||
import { useRouter, useOverlay } from '@/composables'
|
||||
|
||||
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 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 PlaylistFolderContextMenu = defineAsyncComponent(() => import('@/components/playlist/PlaylistFolderContextMenu.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 DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
|
||||
const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue'))
|
||||
|
@ -121,7 +127,7 @@ onMounted(async () => {
|
|||
const initialized = ref(false)
|
||||
|
||||
const init = async () => {
|
||||
overlay.value!.show({ message: 'Just a little patience…' })
|
||||
useOverlay(overlay).showOverlay({ message: 'Just a little patience…' })
|
||||
|
||||
try {
|
||||
await commonStore.init()
|
||||
|
@ -141,7 +147,7 @@ const init = async () => {
|
|||
layout.value = 'auth'
|
||||
throw err
|
||||
} finally {
|
||||
overlay.value!.hide()
|
||||
useOverlay(overlay).hideOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,50 +168,11 @@ provide(CurrentSongKey, currentSong)
|
|||
|
||||
<style lang="postcss">
|
||||
#dragGhost {
|
||||
display: inline-block;
|
||||
background: var(--color-green);
|
||||
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;
|
||||
}
|
||||
@apply inline-block py-2 px-3 rounded-md text-base font-sans fixed top-0 left-0 z-[-1] bg-k-success
|
||||
text-k-text-primary no-hover:hidden;
|
||||
}
|
||||
|
||||
#copyArea {
|
||||
position: absolute;
|
||||
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);
|
||||
}
|
||||
@apply absolute -left-full bottom-px w-px h-px no-hover:hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Ref, ref } from 'vue'
|
||||
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 Overlay from '@/components/ui/Overlay.vue'
|
||||
|
||||
|
|
|
@ -9,24 +9,18 @@
|
|||
@dragstart="onDragStart"
|
||||
>
|
||||
<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>
|
||||
<span v-else class="text-secondary">{{ album.artist_name }}</span>
|
||||
<span v-else class="text-k-text-secondary">{{ album.artist_name }}</span>
|
||||
</template>
|
||||
|
||||
<template #meta>
|
||||
<a
|
||||
:title="`Shuffle all songs in the album ${album.name}`"
|
||||
class="shuffle-album"
|
||||
role="button"
|
||||
@click.prevent="shuffle"
|
||||
>
|
||||
<a :title="`Shuffle all songs in the album ${album.name}`" role="button" @click.prevent="shuffle">
|
||||
Shuffle
|
||||
</a>
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs in the album ${album.name}`"
|
||||
class="download-album"
|
||||
role="button"
|
||||
@click.prevent="download"
|
||||
>
|
||||
|
|
|
@ -1,70 +1,59 @@
|
|||
<template>
|
||||
<article :class="mode" class="album-info artist-album-info" data-testid="album-info">
|
||||
<h1 v-if="mode === 'aside'" class="name">
|
||||
<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>
|
||||
<AlbumArtistInfo :mode="mode" data-testid="album-info">
|
||||
<template #header>{{ album.name }}</template>
|
||||
|
||||
<main>
|
||||
<AlbumThumbnail v-if="mode === 'aside'" :entity="album" />
|
||||
<template #art>
|
||||
<AlbumThumbnail :entity="album" />
|
||||
</template>
|
||||
|
||||
<template v-if="info">
|
||||
<div v-if="info.wiki?.summary" class="wiki">
|
||||
<div v-if="showSummary" class="summary" data-testid="summary" v-html="info.wiki.summary" />
|
||||
<div v-if="showFull" class="full" data-testid="full" v-html="info.wiki.full" />
|
||||
<template v-if="info">
|
||||
<template v-if="info.wiki">
|
||||
<ExpandableContentBlock v-if="mode === 'aside'">
|
||||
<div v-html="info.wiki.full" />
|
||||
</ExpandableContentBlock>
|
||||
|
||||
<button v-if="showSummary" class="more" @click.prevent="showingFullWiki = true">
|
||||
Full Wiki
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TrackList v-if="info.tracks?.length" :album="album" :tracks="info.tracks" data-testid="album-info-tracks" />
|
||||
|
||||
<footer>
|
||||
Data ©
|
||||
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||
</footer>
|
||||
<div v-else v-html="info.wiki.full" />
|
||||
</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 ©
|
||||
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||
</template>
|
||||
</AlbumArtistInfo>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { songStore } from '@/stores'
|
||||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||
import { defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { mediaInfoService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
|
||||
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 props = withDefaults(defineProps<{ album: Album, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
|
||||
const { album, mode } = toRefs(props)
|
||||
|
||||
const { go } = useRouter()
|
||||
const { useLastfm, useSpotify } = useThirdPartyServices()
|
||||
|
||||
const info = ref<AlbumInfo | null>(null)
|
||||
const showingFullWiki = ref(false)
|
||||
|
||||
watch(album, async () => {
|
||||
showingFullWiki.value = false
|
||||
info.value = null
|
||||
|
||||
if (useLastfm.value || useSpotify.value) {
|
||||
info.value = await mediaInfoService.fetchForAlbum(album.value)
|
||||
}
|
||||
}, { 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>
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
<template>
|
||||
<article class="track-listing">
|
||||
<h3>Track Listing</h3>
|
||||
<article>
|
||||
<h3 class="text-2xl mb-3">Track Listing</h3>
|
||||
|
||||
<ul class="tracks">
|
||||
<li v-for="(track, index) in tracks" :key="index" data-testid="album-track-item">
|
||||
<ul>
|
||||
<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" />
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -29,30 +34,19 @@ onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
|
|||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
article {
|
||||
h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
ul {
|
||||
counter-reset: trackCounter;
|
||||
}
|
||||
|
||||
li {
|
||||
counter-increment: trackCounter;
|
||||
|
||||
&::before {
|
||||
content: counter(trackCounter);
|
||||
}
|
||||
|
||||
ul {
|
||||
counter-reset: trackCounter;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
&:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<template>
|
||||
<div
|
||||
class="track-list-item"
|
||||
class="track-list-item flex flex-1 gap-1"
|
||||
:class="{ active, available: matchedSong }"
|
||||
:title="tooltip"
|
||||
tabindex="0"
|
||||
@click="play"
|
||||
>
|
||||
<span class="title">{{ track.title }}</span>
|
||||
<span class="flex-1">{{ track.title }}</span>
|
||||
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl" />
|
||||
<span class="length">{{ fmtLength }}</span>
|
||||
<span class="w-14 text-right opacity-50">{{ fmtLength }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -49,32 +49,17 @@ const play = () => {
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
.track-list-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
|
||||
&:focus, &.active {
|
||||
span.title {
|
||||
color: var(--color-highlight);
|
||||
@apply text-k-highlight;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.length {
|
||||
flex: 0 0 44px;
|
||||
text-align: right;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&.available {
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
@apply cursor-pointer text-k-text-primary;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-highlight);
|
||||
@apply text-k-highlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
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]">
|
||||
<footer data-v-f01bdc56="">
|
||||
<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>
|
||||
<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>
|
||||
<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="" class="tw-flex tw-flex-1 tw-flex-col tw-gap-1.5 tw-overflow-hidden">
|
||||
<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 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>
|
||||
</article>
|
||||
`;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
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="">
|
||||
<li>Play All</li>
|
||||
<li>Shuffle All</li>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div data-v-da281390="" class="track-list-item" title="" tabindex="0"><span data-v-da281390="" class="title">Fahrstuhl to Heaven</span>
|
||||
<!----><span data-v-da281390="" class="length">04:40</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="tw-w-14 tw-text-right tw-opacity-50">04:40</span>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -9,24 +9,13 @@
|
|||
@dragstart="onDragStart"
|
||||
>
|
||||
<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 #meta>
|
||||
<a
|
||||
:title="`Shuffle all songs by ${artist.name}`"
|
||||
class="shuffle-artist"
|
||||
role="button"
|
||||
@click.prevent="shuffle"
|
||||
>
|
||||
<a :title="`Shuffle all songs by ${artist.name}`" role="button" @click.prevent="shuffle">
|
||||
Shuffle
|
||||
</a>
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
:title="`Download all songs by ${artist.name}`"
|
||||
class="download-artist"
|
||||
role="button"
|
||||
@click.prevent="download"
|
||||
>
|
||||
<a v-if="allowDownload" :title="`Download all songs by ${artist.name}`" role="button" @click.prevent="download">
|
||||
Download
|
||||
</a>
|
||||
</template>
|
||||
|
|
|
@ -1,74 +1,47 @@
|
|||
<template>
|
||||
<article :class="mode" class="artist-info artist-album-info" data-testid="artist-info">
|
||||
<h1 v-if="mode === 'aside'" class="name">
|
||||
<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>
|
||||
<AlbumArtistInfo :mode="mode" data-testid="artist-info">
|
||||
<template #header>{{ artist.name }}</template>
|
||||
|
||||
<main>
|
||||
<ArtistThumbnail v-if="mode === 'aside'" :entity="artist" />
|
||||
<template #art>
|
||||
<ArtistThumbnail :entity="artist" />
|
||||
</template>
|
||||
|
||||
<template v-if="info">
|
||||
<div v-if="info.bio?.summary" class="bio">
|
||||
<div v-if="showSummary" class="summary" data-testid="summary" v-html="info.bio.summary" />
|
||||
<div v-if="showFull" class="full" data-testid="full" v-html="info.bio.full" />
|
||||
<template v-if="info?.bio">
|
||||
<ExpandableContentBlock v-if="mode === 'aside'">
|
||||
<div v-html="info.bio.full" />
|
||||
</ExpandableContentBlock>
|
||||
|
||||
<button v-if="showSummary" class="more" @click.prevent="showingFullBio = true">
|
||||
Full Bio
|
||||
</button>
|
||||
</div>
|
||||
<div v-else v-html="info.bio.full" />
|
||||
</template>
|
||||
|
||||
<footer>
|
||||
Data ©
|
||||
<a :href="info.url" rel="openener" target="_blank">Last.fm</a>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</article>
|
||||
<template v-if="info" #footer>
|
||||
Data ©
|
||||
<a :href="info.url" rel="openener" target="_blank">Last.fm</a>
|
||||
</template>
|
||||
</AlbumArtistInfo>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { ref, toRefs, watch } from 'vue'
|
||||
import { mediaInfoService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
|
||||
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 { artist, mode } = toRefs(props)
|
||||
|
||||
const { go } = useRouter()
|
||||
const { useLastfm, useSpotify } = useThirdPartyServices()
|
||||
|
||||
const info = ref<ArtistInfo | null>(null)
|
||||
const showingFullBio = ref(false)
|
||||
|
||||
watch(artist, async () => {
|
||||
showingFullBio.value = false
|
||||
info.value = null
|
||||
|
||||
if (useLastfm.value || useSpotify.value) {
|
||||
info.value = await mediaInfoService.fetchForArtist(artist.value)
|
||||
}
|
||||
}, { 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>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.artist-info {
|
||||
.none {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
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]">
|
||||
<footer data-v-f01bdc56="">
|
||||
<div data-v-f01bdc56="" class="name"><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>
|
||||
<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="" class="tw-flex tw-flex-1 tw-flex-col tw-gap-1.5 tw-overflow-hidden">
|
||||
<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 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>
|
||||
</article>
|
||||
`;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
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="">
|
||||
<li>Play All</li>
|
||||
<li>Shuffle All</li>
|
||||
|
|
|
@ -1,12 +1,23 @@
|
|||
<template>
|
||||
<form data-testid="forgot-password-form" @submit.prevent="requestResetPasswordLink">
|
||||
<h1 class="font-size-1.5">Forgot Password</h1>
|
||||
<form
|
||||
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>
|
||||
<input v-model="email" placeholder="Your email address" required type="email">
|
||||
<Btn :disabled="loading" type="submit">Reset Password</Btn>
|
||||
<Btn :disabled="loading" class="text-secondary" transparent @click="cancel">Cancel</Btn>
|
||||
</div>
|
||||
<FormRow>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:gap-0 sm:content-stretch">
|
||||
<TextInput
|
||||
v-model="email"
|
||||
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>
|
||||
</template>
|
||||
|
||||
|
@ -15,7 +26,9 @@ import { ref } from 'vue'
|
|||
import { authService } from '@/services'
|
||||
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()
|
||||
|
||||
|
@ -37,50 +50,10 @@ const requestResetPasswordLink = async () => {
|
|||
if (err.response.status === 404) {
|
||||
toastError('No user with this email address found.')
|
||||
} else {
|
||||
toastError('An unknown error occurred.')
|
||||
toastError(err.response.data?.message || 'An unknown error occurred.')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -1,30 +1,36 @@
|
|||
<template>
|
||||
<div class="login-wrapper">
|
||||
<div class="flex items-center justify-center min-h-screen my-0 mx-auto flex-col gap-5">
|
||||
<form
|
||||
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 }"
|
||||
data-testid="login-form"
|
||||
@submit.prevent="login"
|
||||
>
|
||||
<div class="logo">
|
||||
<img alt="Koel's logo" src="@/../img/logo.svg" width="156">
|
||||
<div class="text-center mb-8">
|
||||
<img class="inline-block" alt="Koel's logo" src="@/../img/logo.svg" width="156">
|
||||
</div>
|
||||
|
||||
<input v-model="email" autofocus placeholder="Email Address" required type="email">
|
||||
<PasswordField v-model="password" placeholder="Password" required />
|
||||
<FormRow>
|
||||
<TextInput v-model="email" autofocus placeholder="Email Address" required type="email" />
|
||||
</FormRow>
|
||||
|
||||
<Btn type="submit">Log In</Btn>
|
||||
<a
|
||||
v-if="canResetPassword"
|
||||
class="reset-password"
|
||||
role="button"
|
||||
@click.prevent="showForgotPasswordForm"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
<FormRow>
|
||||
<PasswordField v-model="password" placeholder="Password" required />
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
<Btn type="submit">Log In</Btn>
|
||||
</FormRow>
|
||||
|
||||
<FormRow v-if="canResetPassword">
|
||||
<a class="text-right text-[.95rem] text-k-text-secondary" role="button" @click.prevent="showForgotPasswordForm">
|
||||
Forgot password?
|
||||
</a>
|
||||
</FormRow>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
|
@ -38,10 +44,12 @@ import { authService } from '@/services'
|
|||
import { logger } from '@/utils'
|
||||
import { useMessageToaster } from '@/composables'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
import Btn from '@/components/ui/form/Btn.vue'
|
||||
import PasswordField from '@/components/ui/form/PasswordField.vue'
|
||||
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.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 = {
|
||||
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 {
|
||||
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 {
|
||||
border-color: var(--color-red);
|
||||
@apply border-red-500;
|
||||
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>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
<template>
|
||||
<div class="reset-password-wrapper vertical-center">
|
||||
<form v-if="validPayload" @submit.prevent="submit">
|
||||
<h1 class="font-size-1.5">Set New Password</h1>
|
||||
<div>
|
||||
<label>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-screen">
|
||||
<form
|
||||
v-if="validPayload"
|
||||
class="flex flex-col gap-3 sm:w-[480px] sm:bg-white/10 sm:rounded-lg p-7"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h1 class="text-2xl mb-2">Set New Password</h1>
|
||||
<label>
|
||||
<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>
|
||||
<Btn :disabled="loading" type="submit">Save</Btn>
|
||||
</div>
|
||||
|
@ -21,8 +23,8 @@ import { authService } from '@/services'
|
|||
import { base64Decode } from '@/utils'
|
||||
import { useMessageToaster, useRouter } from '@/composables'
|
||||
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/form/PasswordField.vue'
|
||||
import Btn from '@/components/ui/form/Btn.vue'
|
||||
|
||||
const { getRouteParam, go } = useRouter()
|
||||
const { toastSuccess, toastError } = useMessageToaster()
|
||||
|
@ -48,39 +50,9 @@ const submit = async () => {
|
|||
await authService.login(email.value, password.value)
|
||||
setTimeout(() => go('/', true))
|
||||
} 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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -1,10 +1,22 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div data-v-0b0f87ea="" class="login-wrapper">
|
||||
<form data-v-0b0f87ea="" class="" 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-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>
|
||||
<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="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="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]">
|
||||
<!--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>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
|
@ -12,12 +24,24 @@ exports[`renders 1`] = `
|
|||
`;
|
||||
|
||||
exports[`shows Google login button 1`] = `
|
||||
<div data-v-0b0f87ea="" class="login-wrapper">
|
||||
<form data-v-0b0f87ea="" class="" 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-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>
|
||||
<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="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="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]">
|
||||
<!--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>
|
||||
<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-->
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
<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">
|
||||
</button>
|
||||
</template>
|
||||
|
@ -22,13 +27,3 @@ const loginWithGoogle = async () => {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
button {
|
||||
opacity: .5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,42 +1,40 @@
|
|||
<template>
|
||||
<div class="invitation-wrapper vertical-center">
|
||||
<form v-if="userProspect" autocomplete="off" @submit.prevent="submit">
|
||||
<header>
|
||||
<div class="flex items-center justify-center h-screen flex-col">
|
||||
<form
|
||||
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.
|
||||
</header>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Your email
|
||||
<input type="text" :value="userProspect.email" disabled>
|
||||
</label>
|
||||
</div>
|
||||
<FormRow>
|
||||
<template #label>Your email</template>
|
||||
<TextInput v-model="userProspect.email" disabled />
|
||||
</FormRow>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Your name
|
||||
<input
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
data-testid="name"
|
||||
placeholder="Erm… Bruce Dickinson?"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<FormRow>
|
||||
<template #label>Your name</template>
|
||||
<TextInput
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
data-testid="name"
|
||||
placeholder="Erm… Bruce Dickinson?"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Password
|
||||
<PasswordField v-model="password" data-testid="password" required />
|
||||
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small>
|
||||
</label>
|
||||
</div>
|
||||
<FormRow>
|
||||
<template #label>Password</template>
|
||||
<PasswordField v-model="password" data-testid="password" required />
|
||||
<template #help>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</template>
|
||||
</FormRow>
|
||||
|
||||
<div class="form-row">
|
||||
<FormRow>
|
||||
<Btn type="submit" :disabled="loading">Accept & Log In</Btn>
|
||||
</div>
|
||||
</FormRow>
|
||||
</form>
|
||||
|
||||
<p v-if="!validToken">Invalid or expired invite.</p>
|
||||
|
@ -47,14 +45,15 @@
|
|||
import { onMounted, ref } from 'vue'
|
||||
import { invitationService } from '@/services'
|
||||
import { useDialogBox, useRouter } from '@/composables'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
|
||||
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 { getRouteParam, go } = useRouter()
|
||||
const { getRouteParam } = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
|
@ -91,42 +90,3 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
</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>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<form class="license-form" @submit.prevent="validateLicenseKey">
|
||||
<input
|
||||
<form class="license-form flex items-stretch" @submit.prevent="validateLicenseKey">
|
||||
<TextInput
|
||||
v-model="licenseKey"
|
||||
v-koel-focus
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
class="!rounded-r-none"
|
||||
name="license"
|
||||
placeholder="Enter your license key"
|
||||
required
|
||||
:disabled="loading"
|
||||
>
|
||||
<Btn blue type="submit" :disabled="loading">Activate</Btn>
|
||||
/>
|
||||
<Btn :disabled="loading" class="!rounded-l-none" type="submit">Activate</Btn>
|
||||
</form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
@ -18,7 +18,8 @@ import { plusService } from '@/services'
|
|||
import { forceReloadWindow, logger } from '@/utils'
|
||||
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 licenseKey = ref('')
|
||||
|
@ -38,23 +39,3 @@ const validateLicenseKey = async () => {
|
|||
}
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -1,30 +1,18 @@
|
|||
<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 />
|
||||
Upgrade to Plus
|
||||
</a>
|
||||
</Btn>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
import Btn from '@/components/ui/form/Btn.vue'
|
||||
|
||||
const openModal = () => eventBus.emit('MODAL_SHOW_KOEL_PLUS')
|
||||
</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>
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
<template>
|
||||
<div class="plus text-secondary" data-testid="koel-plus" tabindex="0">
|
||||
<img class="plus-icon" alt="Koel Plus" src="@/../img/koel-plus.svg" width="96">
|
||||
<div class="plus text-k-text-secondary max-w-[480px] flex flex-col items-center" data-testid="koel-plus" tabindex="0">
|
||||
<img
|
||||
class="-mt-[48px] rounded-full border-[6px] border-white"
|
||||
alt="Koel Plus"
|
||||
src="@/../img/koel-plus.svg"
|
||||
width="96"
|
||||
>
|
||||
|
||||
<main>
|
||||
<div class="intro">
|
||||
<main class="!px-8 !py-5 text-center flex flex-col gap-5">
|
||||
<div>
|
||||
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
|
||||
in the future!
|
||||
</div>
|
||||
|
||||
<div v-show="!showingActivateLicenseForm" class="buttons" data-testid="buttons">
|
||||
<Btn big red @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn>
|
||||
<Btn big green @click.prevent="showActivateLicenseForm">I have a license key</Btn>
|
||||
<div v-show="!showingActivateLicenseForm" class="space-x-3" data-testid="buttons">
|
||||
<Btn big danger @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn>
|
||||
<Btn big success @click.prevent="showActivateLicenseForm">I have a license key</Btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showingActivateLicenseForm" class="activate-form" data-testid="activateForm">
|
||||
<ActivateLicenseForm v-if="showingActivateLicenseForm" />
|
||||
<Btn transparent class="cancel" @click.prevent="hideActivateLicenseForm">Cancel</Btn>
|
||||
<div v-if="showingActivateLicenseForm" class="flex gap-3" data-testid="activateForm">
|
||||
<ActivateLicenseForm v-if="showingActivateLicenseForm" class="flex-1" />
|
||||
<Btn class="cancel" transparent @click.prevent="hideActivateLicenseForm">Cancel</Btn>
|
||||
</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.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Btn data-testid="close-modal-btn" red rounded @click.prevent="close">Close</Btn>
|
||||
<footer class="w-full text-center bg-black/20">
|
||||
<Btn data-testid="close-modal-btn" danger rounded @click.prevent="close">Close</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -34,7 +39,7 @@
|
|||
import { onMounted, ref } from 'vue'
|
||||
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'
|
||||
|
||||
const { checkoutUrl } = useKoelPlus()
|
||||
|
@ -54,63 +59,3 @@ const hideActivateLicenseForm = () => (showingActivateLicenseForm.value = false)
|
|||
|
||||
onMounted(() => window.createLemonSqueezy?.())
|
||||
</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>
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
<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" />
|
||||
</dialog>
|
||||
</template>
|
||||
|
@ -23,7 +28,7 @@ const modalNameToComponentMap = {
|
|||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/PlaylistCollaborationModal.vue')),
|
||||
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.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
|
||||
|
@ -87,62 +92,22 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
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) {
|
||||
position: relative;
|
||||
@apply relative;
|
||||
|
||||
> header, > main, > footer {
|
||||
padding: 1.2rem;
|
||||
@apply px-6 py-5;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 0;
|
||||
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;
|
||||
}
|
||||
@apply mt-0 bg-black/10 border-t border-white/5 space-x-2;
|
||||
}
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
background: var(--color-bg-secondary);
|
||||
@apply flex bg-k-bg-secondary;
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
line-height: 2.2rem;
|
||||
margin-bottom: .3rem;
|
||||
@apply text-3xl leading-normal overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,63 +2,42 @@
|
|||
<!--
|
||||
A very thin wrapper around Plyr, extracted as a standalone component for easier styling and to work better with HMR.
|
||||
-->
|
||||
<div class="plyr">
|
||||
<audio controls crossorigin="anonymous" />
|
||||
<div class="plyr w-full h-[4px]">
|
||||
<audio class="hidden" controls crossorigin="anonymous" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
/* can't be scoped as it would be overridden by the plyr css */
|
||||
.plyr {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plyr__controls {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0 !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@apply bg-transparent shadow-none absolute top-0 w-full;
|
||||
@apply p-0 !important;
|
||||
}
|
||||
|
||||
.plyr__progress--played[value] {
|
||||
transition: .3s ease-in-out;
|
||||
color: rgba(255, 255, 255, .1);
|
||||
@apply transition duration-300 ease-in-out text-white/10;
|
||||
|
||||
:fullscreen & {
|
||||
color: rgba(255, 255, 255, .5);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
@apply text-white/50 rounded-full overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.plyr__progress--played[value] {
|
||||
color: var(--color-highlight);
|
||||
@apply text-k-highlight;
|
||||
}
|
||||
}
|
||||
|
||||
@media(hover: none) {
|
||||
.plyr__progress--played[value] {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
.plyr__progress--played[value] {
|
||||
@apply no-hover:text-k-highlight;
|
||||
}
|
||||
|
||||
:fullscreen & {
|
||||
z-index: 4;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
@apply z-[4] bg-white/20 rounded-full;
|
||||
|
||||
.plyr__progress--played[value] {
|
||||
color: #fff !important;
|
||||
@apply text-white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -1,38 +1,30 @@
|
|||
<template>
|
||||
<div class="extra-controls" data-testid="other-controls">
|
||||
<div class="wrapper">
|
||||
<button
|
||||
v-koel-tooltip.top
|
||||
class="visualizer-btn"
|
||||
<div class="extra-controls flex justify-end relative md:w-[320px] px-8 py-0">
|
||||
<div class="flex justify-end items-center gap-6">
|
||||
<FooterBtn
|
||||
class="visualizer-btn hidden md:!block"
|
||||
data-testid="toggle-visualizer-btn"
|
||||
title="Toggle visualizer"
|
||||
@click.prevent="toggleVisualizer"
|
||||
>
|
||||
<Icon :icon="faBolt" />
|
||||
</button>
|
||||
</FooterBtn>
|
||||
|
||||
<button
|
||||
<FooterBtn
|
||||
v-if="useEqualizer"
|
||||
v-koel-tooltip.top
|
||||
:class="{ active: showEqualizer }"
|
||||
class="equalizer"
|
||||
title="Show equalizer"
|
||||
type="button"
|
||||
@click.prevent="showEqualizer"
|
||||
>
|
||||
<Icon :icon="faSliders" />
|
||||
</button>
|
||||
</FooterBtn>
|
||||
|
||||
<VolumeSlider />
|
||||
|
||||
<button
|
||||
v-if="isFullscreenSupported()"
|
||||
v-koel-tooltip.top
|
||||
:title="fullscreenButtonTitle"
|
||||
@click.prevent="toggleFullscreen"
|
||||
>
|
||||
<FooterBtn v-if="isFullscreenSupported()" :title="fullscreenButtonTitle" @click.prevent="toggleFullscreen">
|
||||
<Icon :icon="isFullscreen ? faCompress : faExpand" />
|
||||
</button>
|
||||
</FooterBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -44,6 +36,7 @@ import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupporte
|
|||
import { useRouter } from '@/composables'
|
||||
|
||||
import VolumeSlider from '@/components/ui/VolumeSlider.vue'
|
||||
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
|
||||
|
@ -64,44 +57,13 @@ onMounted(() => {
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
.extra-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
width: 320px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 2rem;
|
||||
|
||||
:fullscreen & {
|
||||
padding-right: 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;
|
||||
}
|
||||
@apply pr-0;
|
||||
}
|
||||
|
||||
:fullscreen & {
|
||||
.visualizer-btn {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
<template>
|
||||
<div class="playback-controls" data-testid="footer-middle-pane">
|
||||
<div class="buttons">
|
||||
<LikeButton v-if="song" :song="song" class="like-btn" />
|
||||
<button v-else type="button" /> <!-- a placeholder to maintain the flex layout -->
|
||||
<div class="playback-controls flex flex-1 flex-col place-content-center place-items-center">
|
||||
<div class="flex items-center justify-center gap-5 md:gap-12">
|
||||
<LikeButton v-if="song" :song="song" class="text-base" />
|
||||
<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" />
|
||||
</button>
|
||||
</FooterBtn>
|
||||
|
||||
<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" />
|
||||
</button>
|
||||
</FooterBtn>
|
||||
|
||||
<RepeatModeSwitch class="repeat-mode-btn" />
|
||||
<RepeatModeSwitch class="text-base" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -29,6 +29,7 @@ import { CurrentSongKey } from '@/symbols'
|
|||
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
import PlayButton from '@/components/ui/FooterPlayButton.vue'
|
||||
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
|
||||
|
||||
const song = requireInjection(CurrentSongKey, ref())
|
||||
|
||||
|
@ -37,44 +38,7 @@ const playNext = async () => await playbackService.playNext()
|
|||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.playback-controls {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
:fullscreen .playback-controls {
|
||||
@apply scale-125;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,13 +2,18 @@
|
|||
<div
|
||||
:class="{ playing: song?.playback_state === 'Playing' }"
|
||||
: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"
|
||||
>
|
||||
<span :style="{ backgroundImage: `url('${cover}')` }" class="album-thumb" />
|
||||
<div v-if="song" class="meta">
|
||||
<h3 class="title">{{ song.title }}</h3>
|
||||
<a :href="`/#/artist/${song.artist_id}`" class="artist">{{ song.artist_name }}</a>
|
||||
<span class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover" />
|
||||
<div v-if="song" class="meta overflow-hidden hidden md:block">
|
||||
<h3 class="title text-ellipsis overflow-hidden whitespace-nowrap">{{ song.title }}</h3>
|
||||
<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>
|
||||
</template>
|
||||
|
@ -24,6 +29,7 @@ const { startDragging } = useDraggable('songs')
|
|||
const song = requireInjection(CurrentSongKey, ref())
|
||||
|
||||
const cover = computed(() => song.value?.album_cover || defaultCover)
|
||||
const coverBackgroundImage = computed(() => `url(${ cover.value })`)
|
||||
const draggable = computed(() => Boolean(song.value))
|
||||
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
|
@ -35,82 +41,35 @@ const onDragStart = (event: DragEvent) => {
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
.song-info {
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 320px;
|
||||
gap: 1rem;
|
||||
|
||||
:fullscreen & {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
width: 84px;
|
||||
@apply pl-0;
|
||||
}
|
||||
|
||||
.album-thumb {
|
||||
display: block;
|
||||
height: 75%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
height: 55%;
|
||||
}
|
||||
background-image: v-bind(coverBackgroundImage);
|
||||
|
||||
:fullscreen & {
|
||||
height: 5rem;
|
||||
@apply h-20;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
|
||||
> * {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:fullscreen & {
|
||||
margin-top: -18rem;
|
||||
transform-origin: left bottom;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
@apply -mt-72 origin-bottom-left absolute overflow-hidden;
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
margin-bottom: .4rem;
|
||||
line-height: 1.2;
|
||||
font-weight: var(--font-weight-bold);
|
||||
@apply text-5xl mb-[0.4rem] font-bold;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 1.6rem;
|
||||
width: fit-content;
|
||||
line-height: 1.2;
|
||||
@apply text-3xl w-fit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.artist {
|
||||
display: block;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
&.playing .album-thumb {
|
||||
@apply motion-reduce:animate-none;
|
||||
animation: spin 30s linear infinite;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div data-v-8bf5fe81="" class="extra-controls" data-testid="other-controls">
|
||||
<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>
|
||||
<!--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>
|
||||
<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="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="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-->
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders with a current song 1`] = `
|
||||
<div data-v-2e8b419d="" class="playback-controls" data-testid="footer-middle-pane">
|
||||
<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="playback-controls tw-flex tw-flex-1 tw-flex-col tw-place-content-center tw-place-items-center">
|
||||
<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="M3 11v-1a4 4 0 0 1 4-4h14"></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`] = `
|
||||
<div data-v-2e8b419d="" class="playback-controls" data-testid="footer-middle-pane">
|
||||
<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="playback-controls tw-flex tw-flex-1 tw-flex-col tw-place-content-center tw-place-items-center">
|
||||
<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="M3 11v-1a4 4 0 0 1 4-4h14"></path>
|
||||
<path d="m7 22-4-4 4-4"></path>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
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="meta">
|
||||
<h3 data-v-91ed60f7="" class="title">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artist/10" class="artist">Led Zeppelin</a>
|
||||
<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 tw-overflow-hidden tw-hidden md:tw-block">
|
||||
<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>
|
||||
`;
|
||||
|
||||
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-->
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<footer
|
||||
id="mainFooter"
|
||||
ref="root"
|
||||
class="flex flex-col relative z-20 bg-k-bg-secondary h-k-footer-height"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
@mousemove="showControls"
|
||||
>
|
||||
<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 />
|
||||
<PlaybackControls />
|
||||
<ExtraControls />
|
||||
|
@ -47,12 +47,9 @@ watch(song, async () => {
|
|||
artist.value = await artistStore.resolve(song.value.artist_id)
|
||||
})
|
||||
|
||||
const styles = computed(() => {
|
||||
const backgroundBackgroundImage = computed(() => {
|
||||
const src = artist.value?.image ?? song.value?.album_cover
|
||||
|
||||
return {
|
||||
backgroundImage: src ? `url(${src})` : 'none'
|
||||
}
|
||||
return src ? `url(${src})` : 'none'
|
||||
})
|
||||
|
||||
const initPlaybackRelatedServices = async () => {
|
||||
|
@ -89,7 +86,7 @@ const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(root)
|
|||
|
||||
watch(isFullscreen, fullscreen => {
|
||||
if (fullscreen) {
|
||||
setupControlHidingTimer()
|
||||
// setupControlHidingTimer()
|
||||
root.value?.classList.remove('hide-controls')
|
||||
} else {
|
||||
window.clearTimeout(hideControlsTimeout)
|
||||
|
@ -101,84 +98,47 @@ eventBus.on('FULLSCREEN_TOGGLE', () => toggleFullscreen())
|
|||
|
||||
<style lang="postcss" scoped>
|
||||
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);
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fullscreen-backdrop {
|
||||
display: none;
|
||||
background-image: v-bind(backgroundBackgroundImage);
|
||||
}
|
||||
|
||||
&:fullscreen {
|
||||
padding: calc(100vh - 9rem) 5vw 0;
|
||||
background: none;
|
||||
@apply bg-none;
|
||||
|
||||
&.hide-controls :not(.fullscreen-backdrop) {
|
||||
transition: opacity 2s ease-in-out;
|
||||
opacity: 0;
|
||||
transition: opacity 2s ease-in-out !important; /* overriding all children's custom transition, if any */
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
z-index: 3;
|
||||
@apply z-[3]
|
||||
}
|
||||
|
||||
&::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%),
|
||||
linear-gradient(225deg, #111 25%, transparent 25%),
|
||||
linear-gradient(45deg, #111 25%, transparent 25%),
|
||||
linear-gradient(315deg, #111 25%, rgba(255, 255, 255, 0) 25%);
|
||||
background-position: 6px 0, 6px 0, 0 0, 0 0;
|
||||
background-size: 6px 6px;
|
||||
background-repeat: repeat;
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: calc(100% + 40rem);
|
||||
height: calc(100% + 40rem);
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: .5;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
margin: -20rem;
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-image: linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(255, 255, 255, 0) 30vh);
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
@apply absolute w-full h-full top-0 left-0 z-[1] pointer-events-none;
|
||||
}
|
||||
|
||||
.fullscreen-backdrop {
|
||||
filter: saturate(.2);
|
||||
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;
|
||||
@apply saturate-[0.2] block absolute top-0 left-0 w-full h-full z-0 bg-cover bg-no-repeat bg-top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,8 @@
|
|||
<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
|
||||
lists), so we use v-show.
|
||||
|
@ -73,60 +76,5 @@ const screen = ref<ScreenName>('Home')
|
|||
|
||||
onRouteChanged(route => (screen.value = route.screen))
|
||||
|
||||
onMounted(async () => {
|
||||
screen.value = getCurrentScreen()
|
||||
})
|
||||
onMounted(() => (screen.value = getCurrentScreen()))
|
||||
</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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,45 +1,41 @@
|
|||
<template>
|
||||
<button
|
||||
<ExtraDrawerButton
|
||||
id="extraTabLyrics"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Lyrics' }"
|
||||
title="Lyrics"
|
||||
type="button"
|
||||
@click.prevent="toggleTab('Lyrics')"
|
||||
>
|
||||
<Icon :icon="faFeather" fixed-width />
|
||||
</button>
|
||||
<button
|
||||
</ExtraDrawerButton>
|
||||
<ExtraDrawerButton
|
||||
id="extraTabArtist"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Artist' }"
|
||||
title="Artist information"
|
||||
type="button"
|
||||
@click.prevent="toggleTab('Artist')"
|
||||
>
|
||||
<Icon :icon="faMicrophone" fixed-width />
|
||||
</button>
|
||||
<button
|
||||
</ExtraDrawerButton>
|
||||
<ExtraDrawerButton
|
||||
id="extraTabAlbum"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Album' }"
|
||||
title="Album information"
|
||||
type="button"
|
||||
@click.prevent="toggleTab('Album')"
|
||||
>
|
||||
<Icon :icon="faCompactDisc" fixed-width />
|
||||
</button>
|
||||
<button
|
||||
</ExtraDrawerButton>
|
||||
<ExtraDrawerButton
|
||||
v-if="useYouTube"
|
||||
id="extraTabYouTube"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'YouTube' }"
|
||||
title="Related YouTube videos"
|
||||
type="button"
|
||||
@click.prevent="toggleTab('YouTube')"
|
||||
>
|
||||
<Icon :icon="faYoutube" fixed-width />
|
||||
</button>
|
||||
</ExtraDrawerButton>
|
||||
</template>
|
||||
|
||||
<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 { computed } from 'vue'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import ExtraDrawerButton from '@/components/layout/main-wrapper/extra-drawer/ExtraDrawerButton.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue?: ExtraPanelTab | null }>(), {
|
||||
modelValue: null
|
||||
|
@ -63,7 +60,3 @@ const value = computed({
|
|||
|
||||
const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? null : tab)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -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>
|
|
@ -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>
|
||||
`;
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div id="mainWrapper">
|
||||
<div class="relative flex flex-1 overflow-hidden">
|
||||
<SideBar />
|
||||
<MainContent />
|
||||
<ExtraDrawer />
|
||||
|
@ -12,17 +12,7 @@ import { defineAsyncComponent } from 'vue'
|
|||
|
||||
import SideBar from '@/components/layout/main-wrapper/sidebar/Sidebar.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'))
|
||||
</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>
|
||||
|
|
|
@ -1,31 +1,42 @@
|
|||
<template>
|
||||
<li
|
||||
class="playlist-folder"
|
||||
:class="{ droppable }"
|
||||
tabindex="0"
|
||||
class="playlist-folder relative"
|
||||
draggable="true"
|
||||
tabindex="0"
|
||||
@dragleave="onDragLeave"
|
||||
@dragover="onDragOver"
|
||||
@dragstart="onDragStart"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu">
|
||||
<Icon :icon="opened ? faFolderOpen : faFolder" fixed-width />
|
||||
<span>{{ folder.name }}</span>
|
||||
</a>
|
||||
<ul>
|
||||
<SidebarItem @click="toggle" @contextmenu.prevent="onContextMenu">
|
||||
<template #icon>
|
||||
<Icon :icon="opened ? faFolderOpen : faFolder" fixed-width />
|
||||
</template>
|
||||
{{ folder.name }}
|
||||
</SidebarItem>
|
||||
|
||||
<ul v-if="playlistsInFolder.length" v-show="opened">
|
||||
<PlaylistSidebarItem v-for="playlist in playlistsInFolder" :key="playlist.id" :list="playlist" class="sub-item" />
|
||||
<li v-if="playlistsInFolder.length" v-show="opened">
|
||||
<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>
|
||||
|
||||
<div
|
||||
v-if="opened"
|
||||
:class="droppableOnHatch && 'droppable'"
|
||||
class="hatch"
|
||||
@dragover="onDragOverHatch"
|
||||
@dragleave.prevent="onDragLeaveHatch"
|
||||
@drop.prevent="onDropOnHatch"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
|
@ -36,7 +47,8 @@ import { playlistFolderStore, playlistStore } from '@/stores'
|
|||
import { eventBus } from '@/utils'
|
||||
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 { folder } = toRefs(props)
|
||||
|
@ -108,28 +120,11 @@ const onContextMenu = (event: MouseEvent) => eventBus.emit(
|
|||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
li.playlist-folder {
|
||||
position: relative;
|
||||
.droppable {
|
||||
@apply ring-1 ring-offset-0 ring-k-accent rounded-md cursor-copy;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
.hatch.droppable {
|
||||
@apply border-b-[3px] border-k-highlight;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<li
|
||||
ref="el"
|
||||
:class="{ droppable }"
|
||||
class="playlist"
|
||||
<SidebarItem
|
||||
:class="{ current, droppable }"
|
||||
:href="url"
|
||||
class="playlist select-none"
|
||||
draggable="true"
|
||||
@contextmenu="onContextMenu"
|
||||
@dragleave="onDragLeave"
|
||||
|
@ -10,15 +10,15 @@
|
|||
@dragstart="onDragStart"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<a :class="{ active }" :href="url">
|
||||
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width />
|
||||
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
|
||||
<template #icon>
|
||||
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-k-success" 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_collaborative" :icon="faUsers" fixed-width />
|
||||
<ListMusic v-else :size="16" />
|
||||
<span>{{ list.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
{{ list.name }}
|
||||
</SidebarItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -34,6 +34,8 @@ import { eventBus } from '@/utils'
|
|||
import { favoriteStore } from '@/stores'
|
||||
import { useDraggable, useDroppable, usePlaylistManagement, useRouter } from '@/composables'
|
||||
|
||||
import SidebarItem from '@/components/layout/main-wrapper/sidebar/SidebarItem.vue'
|
||||
|
||||
const { onRouteChanged } = useRouter()
|
||||
const { startDragging } = useDraggable('playlist')
|
||||
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 isRecentlyPlayedList = (list: PlaylistLike): list is RecentlyPlayedList => list.name === 'Recently Played'
|
||||
|
||||
const active = ref(false)
|
||||
const current = ref(false)
|
||||
|
||||
const url = computed(() => {
|
||||
if (isPlaylist(list.value)) return `#/playlist/${list.value.id}`
|
||||
|
@ -109,38 +111,26 @@ const onDrop = async (event: DragEvent) => {
|
|||
onRouteChanged(route => {
|
||||
switch (route.screen) {
|
||||
case 'Favorites':
|
||||
active.value = isFavoriteList(list.value)
|
||||
current.value = isFavoriteList(list.value)
|
||||
break
|
||||
|
||||
case 'RecentlyPlayed':
|
||||
active.value = isRecentlyPlayedList(list.value)
|
||||
current.value = isRecentlyPlayedList(list.value)
|
||||
break
|
||||
|
||||
case 'Playlist':
|
||||
active.value = (list.value as Playlist).id === route.params!.id
|
||||
current.value = (list.value as Playlist).id === route.params!.id
|
||||
break
|
||||
|
||||
default:
|
||||
active.value = false
|
||||
current.value = false
|
||||
break
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.playlist {
|
||||
user-select: none;
|
||||
|
||||
&.droppable {
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
border-radius: 4px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
span {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.droppable {
|
||||
@apply ring-1 ring-offset-0 ring-k-accent rounded-md cursor-copy;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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>
|
|
@ -2,12 +2,14 @@
|
|||
<SidebarItem
|
||||
screen="Queue"
|
||||
href="#/queue"
|
||||
:icon="faListOl"
|
||||
:class="droppable && 'droppable'"
|
||||
@dragleave="onQueueDragLeave"
|
||||
@dragover.prevent="onQueueDragOver"
|
||||
@drop="onQueueDrop"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon :icon="faListOl" fixed-width />
|
||||
</template>
|
||||
Current Queue
|
||||
</SidebarItem>
|
||||
</template>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import { it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { commonStore } from '@/stores'
|
||||
|
|
|
@ -1,108 +1,69 @@
|
|||
<template>
|
||||
<nav
|
||||
id="sidebar"
|
||||
v-koel-clickaway="closeIfMobile"
|
||||
:class="{ collapsed, 'tmp-showing': tmpShowing, showing: mobileShowing }"
|
||||
class="side side-nav"
|
||||
:class="{ collapsed: !expanded, 'tmp-showing': tmpShowing, showing: mobileShowing }"
|
||||
class="flex flex-col pb-4 fixed md:relative w-full md:w-k-sidebar-width z-10"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<section class="search-wrapper">
|
||||
<section class="search-wrapper p-6">
|
||||
<SearchForm />
|
||||
</section>
|
||||
|
||||
<section v-koel-overflow-fade class="menu-wrapper">
|
||||
<section class="music">
|
||||
<h1>Your Music</h1>
|
||||
|
||||
<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 v-koel-overflow-fade class="py-0 px-6 overflow-y-auto space-y-8">
|
||||
<SidebarYourMusicSection />
|
||||
<SidebarPlaylistsSection />
|
||||
<SidebarManageSection v-if="showManageSection" />
|
||||
</section>
|
||||
|
||||
<section v-if="!isPlus && isAdmin" class="plus-wrapper">
|
||||
<section v-if="!isPlus && isAdmin" class="p-6">
|
||||
<BtnUpgradeToPlus />
|
||||
</section>
|
||||
|
||||
<button class="btn-toggle" @click.prevent="toggleNavbar">
|
||||
<Icon v-if="collapsed" :icon="faAngleRight" />
|
||||
<Icon v-else :icon="faAngleLeft" />
|
||||
</button>
|
||||
<SidebarToggleButton v-model="expanded" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
faCompactDisc,
|
||||
faHome,
|
||||
faMicrophone,
|
||||
faMusic,
|
||||
faTags,
|
||||
faTools,
|
||||
faUpload,
|
||||
faUsers,
|
||||
faAngleLeft,
|
||||
faAngleRight
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
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 QueueSidebarItem from './QueueSidebarItem.vue'
|
||||
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
|
||||
import PlaylistList from './PlaylistSidebarList.vue'
|
||||
import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
|
||||
import SearchForm from '@/components/ui/SearchForm.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 { useYouTube } = useThirdPartyServices()
|
||||
const { isAdmin } = useAuthorization()
|
||||
const { allowsUpload } = useUpload()
|
||||
const { isPlus } = useKoelPlus()
|
||||
const { get: lsGet, set: lsSet } = useLocalStorage()
|
||||
|
||||
const collapsed = ref(lsGet('sidebar-collapsed', 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 closeIfMobile = () => (mobileShowing.value = false)
|
||||
const toggleNavbar = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
lsSet('sidebar-collapsed', collapsed.value)
|
||||
}
|
||||
|
||||
let tmpShowingHandler: number | undefined
|
||||
const tmpShowing = ref(false)
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (!collapsed.value) return;
|
||||
if (expanded.value) return;
|
||||
|
||||
tmpShowingHandler = window.setTimeout(() => {
|
||||
if (!collapsed.value) return
|
||||
if (expanded.value) return
|
||||
tmpShowing.value = true
|
||||
}, 500)
|
||||
}
|
||||
|
@ -127,184 +88,42 @@ onRouteChanged(_ => (mobileShowing.value = false))
|
|||
* This should only be triggered on a mobile device.
|
||||
*/
|
||||
eventBus.on('TOGGLE_SIDEBAR', () => (mobileShowing.value = !mobileShowing.value))
|
||||
.on('PLAY_YOUTUBE_VIDEO', _ => (youTubePlaying.value = true))
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@import '@/../css/partials/mixins.pcss';
|
||||
|
||||
nav {
|
||||
position: relative;
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--color-bg-secondary);
|
||||
@apply bg-k-bg-secondary;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: width;
|
||||
|
||||
&.collapsed {
|
||||
transition: width .2s;
|
||||
width: 24px;
|
||||
@apply w-[24px] transition-[width] duration-200;
|
||||
|
||||
> *:not(.btn-toggle) {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&.tmp-showing {
|
||||
position: absolute;
|
||||
background-color: var(--color-bg-primary);
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
@apply absolute h-screen z-50 bg-k-bg-primary w-k-sidebar-width shadow-2xl;
|
||||
|
||||
> *: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) {
|
||||
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);
|
||||
@mixin themed-background;
|
||||
|
||||
transform: translateX(-100vw);
|
||||
transition: transform .2s ease-in-out;
|
||||
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 99;
|
||||
height: calc(100vh - var(--header-height));
|
||||
|
||||
&.showing {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
<template>
|
||||
<li>
|
||||
<a :class="active && 'active'" :href="props.href">
|
||||
<Icon :icon="props.icon" fixed-width />
|
||||
<span>
|
||||
<li
|
||||
:class="current && 'current'"
|
||||
class="relative before:-right-6 before:top-1/4 before:w-[4px] before:h-1/2 before:absolute before:rounded-full
|
||||
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 />
|
||||
</span>
|
||||
</a>
|
||||
|
@ -10,14 +21,32 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Component, ref } from 'vue'
|
||||
import { useRouter } from '@/composables'
|
||||
import { ref } from 'vue'
|
||||
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()
|
||||
|
||||
onRouteChanged(route => active.value = route.screen === props.screen)
|
||||
if (screen) {
|
||||
onRouteChanged(route => current.value = route.screen === props.screen)
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -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>
|
|
@ -3,13 +3,13 @@ import { it } from 'vitest'
|
|||
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import PlaylistSidebarList from './PlaylistSidebarList.vue'
|
||||
import SidebarPlaylistsSection from './SidebarPlaylistsSection.vue'
|
||||
import PlaylistSidebarItem from './PlaylistSidebarItem.vue'
|
||||
import PlaylistFolderSidebarItem from './PlaylistFolderSidebarItem.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent () {
|
||||
this.render(PlaylistSidebarList, {
|
||||
this.render(SidebarPlaylistsSection, {
|
||||
global: {
|
||||
stubs: {
|
||||
PlaylistSidebarItem,
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<section class="space-y-4">
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<h3 class="uppercase tracking-widest mb-3">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,8 +1,14 @@
|
|||
<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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { unescape } from 'lodash'
|
||||
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
|
@ -11,5 +17,5 @@ import SidebarItem from './SidebarItem.vue'
|
|||
|
||||
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>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// 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>`;
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// 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>`;
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
<template>
|
||||
<div v-koel-focus class="about text-secondary" data-testid="about-koel" tabindex="0" @keydown.esc="close">
|
||||
<main>
|
||||
<div class="logo">
|
||||
<img alt="Koel's logo" src="@/../img/logo.svg" width="128">
|
||||
<div
|
||||
v-koel-focus
|
||||
class="about text-k-text-secondary text-center max-w-[480px] overflow-hidden relative"
|
||||
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 class="current-version">
|
||||
|
@ -13,13 +19,13 @@
|
|||
<p v-if="isPlus" class="plus-badge">
|
||||
Licensed to {{ license.customerName }} <{{ license.customerEmail }}>
|
||||
<br>
|
||||
License key: <span class="key">{{ license.shortKey }}</span>
|
||||
License key: <span class="key font-mono">{{ license.shortKey }}</span>
|
||||
</p>
|
||||
|
||||
<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 -->
|
||||
<BtnUpgradeToPlus @click.prevent="showPlusModal" />
|
||||
<BtnUpgradeToPlus class="!w-auto inline-block !px-6" @click.prevent="showPlusModal" />
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -35,19 +41,11 @@
|
|||
<a href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a>
|
||||
and quite a few
|
||||
<a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a> <a
|
||||
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
|
||||
>contributors</a>.
|
||||
href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank"
|
||||
>contributors</a>.
|
||||
</p>
|
||||
|
||||
<div v-if="credits" class="credit-wrapper" data-testid="demo-credits">
|
||||
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>
|
||||
|
||||
<CreditsBlock v-if="isDemo" />
|
||||
<SponsorList />
|
||||
|
||||
<p v-if="!isPlus">
|
||||
|
@ -59,28 +57,19 @@
|
|||
</main>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { orderBy } from 'lodash'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuthorization, useKoelPlus, useNewVersionNotification } from '@/composables'
|
||||
import { http } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
|
||||
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'
|
||||
|
||||
type DemoCredits = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const credits = ref<DemoCredits[] | null>(null)
|
||||
import CreditsBlock from '@/components/meta/CreditsBlock.vue'
|
||||
|
||||
const {
|
||||
shouldNotifyNewVersion,
|
||||
|
@ -100,87 +89,23 @@ const showPlusModal = () => {
|
|||
eventBus.emit('MODAL_SHOW_KOEL_PLUS')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
credits.value = window.IS_DEMO ? orderBy(await http.get<DemoCredits[]>('demo/credits'), 'name') : null
|
||||
})
|
||||
const isDemo = window.IS_DEMO;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.about {
|
||||
text-align: center;
|
||||
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);
|
||||
}
|
||||
}
|
||||
p {
|
||||
@apply mx-0 my-3;
|
||||
}
|
||||
|
||||
.credit-wrapper {
|
||||
max-height: 9rem;
|
||||
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;
|
||||
a {
|
||||
@apply text-k-text-primary hover:text-k-accent;
|
||||
}
|
||||
|
||||
.plus-badge {
|
||||
.key {
|
||||
font-family: monospace;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: linear-gradient(97.78deg, #c62be8 17.5%, #671ce4 113.39%);
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade {
|
||||
padding: .5rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
45
resources/assets/js/components/meta/CreditsBlock.vue
Normal file
45
resources/assets/js/components/meta/CreditsBlock.vue
Normal 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>
|
|
@ -1,36 +1,26 @@
|
|||
<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
|
||||
v-for="sponsor in sponsors"
|
||||
:key="sponsor.url"
|
||||
:href="sponsor.url"
|
||||
:title="sponsor.description"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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>
|
||||
import sponsors from '@/sponsors'
|
||||
</script>
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
<template>
|
||||
<div v-if="shown" class="support-bar" data-testid="support-bar">
|
||||
<p>
|
||||
<div
|
||||
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
|
||||
<a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a>
|
||||
and/or
|
||||
<a href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>.
|
||||
</p>
|
||||
<button type="button" @click.prevent="close">Hide</button>
|
||||
<span class="sep" />
|
||||
<span class="block after:content-['•'] after:block" />
|
||||
<button type="button" @click.prevent="stopBugging">
|
||||
Don't bug me again
|
||||
</button>
|
||||
|
@ -43,46 +47,11 @@ watch(preferenceStore.initialized, initialized => {
|
|||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.support-bar {
|
||||
background: var(--color-bg-primary);
|
||||
font-size: .9rem;
|
||||
padding: .75rem 1rem;
|
||||
display: flex;
|
||||
color: rgba(255, 255, 255, .6);
|
||||
z-index: 9;
|
||||
a {
|
||||
@apply text-k-text-primary hover:text-k-accent;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
button {
|
||||
@apply text-k-text-primary text-[0.9rem] hover:text-k-accent;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -12,6 +12,6 @@ exports[`renders 1`] = `
|
|||
<!--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>
|
||||
</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>
|
||||
`;
|
||||
|
|
|
@ -3,7 +3,8 @@ import { screen, waitFor } from '@testing-library/vue'
|
|||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
import { Events } from '@/config'
|
||||
import CreateNewPlaylistContextMenu from './CreateNewPlaylistContextMenu.vue'
|
||||
|
||||
import CreateNewPlaylistContextMenu from './CreatePlaylistContextMenu.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private async renderComponent () {
|
|
@ -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>
|
|
@ -5,16 +5,15 @@
|
|||
</header>
|
||||
|
||||
<main>
|
||||
<div class="form-row">
|
||||
<input
|
||||
<FormRow>
|
||||
<TextInput
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
name="name"
|
||||
placeholder="Folder name"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
/>
|
||||
</FormRow>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
@ -30,7 +29,9 @@ import { playlistFolderStore } from '@/stores'
|
|||
import { logger } from '@/utils'
|
||||
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 { toastSuccess } = useMessageToaster()
|
||||
|
|
|
@ -1,39 +1,28 @@
|
|||
<template>
|
||||
<form @submit.prevent="submit" @keydown.esc="maybeClose">
|
||||
<form class="min-w-full" @submit.prevent="submit" @keydown.esc="maybeClose">
|
||||
<header>
|
||||
<h1>
|
||||
New Playlist
|
||||
<span
|
||||
v-if="songs.length"
|
||||
data-testid="from-songs"
|
||||
class="text-secondary"
|
||||
>
|
||||
<span v-if="songs.length" data-testid="from-songs" class="text-k-text-secondary">
|
||||
from {{ pluralize(songs, 'song') }}
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="form-row cols">
|
||||
<label class="name">
|
||||
Name
|
||||
<input
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
name="name"
|
||||
placeholder="Playlist name"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
</label>
|
||||
<label class="folder">
|
||||
Folder
|
||||
<select v-model="folderId">
|
||||
<FormRow :cols="2">
|
||||
<FormRow>
|
||||
<template #label>Name</template>
|
||||
<TextInput v-model="name" v-koel-focus name="name" placeholder="Playlist name" required />
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<template #label>Folder</template>
|
||||
<SelectBox v-model="folderId">
|
||||
<option :value="null" />
|
||||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</SelectBox>
|
||||
</FormRow>
|
||||
</FormRow>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
@ -49,7 +38,10 @@ import { playlistFolderStore, playlistStore } from '@/stores'
|
|||
import { logger, pluralize } from '@/utils'
|
||||
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 { toastSuccess } = useMessageToaster()
|
||||
|
@ -97,13 +89,3 @@ const maybeClose = async () => {
|
|||
await showConfirmDialog('Discard all changes?') && close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
form {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
label.folder {
|
||||
flex: .6;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,17 +5,9 @@
|
|||
</header>
|
||||
|
||||
<main>
|
||||
<div class="form-row">
|
||||
<input
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
name="name"
|
||||
placeholder="Folder name"
|
||||
required
|
||||
title="Folder name"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<FormRow>
|
||||
<TextInput v-model="name" v-koel-focus name="name" placeholder="Folder name" required title="Folder name" />
|
||||
</FormRow>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
@ -31,7 +23,9 @@ import { logger } from '@/utils'
|
|||
import { playlistFolderStore } from '@/stores'
|
||||
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 { toastSuccess } = useMessageToaster()
|
||||
|
|
|
@ -5,27 +5,26 @@
|
|||
</header>
|
||||
|
||||
<main>
|
||||
<div class="form-row cols">
|
||||
<label class="name">
|
||||
Name
|
||||
<input
|
||||
<FormRow :cols="2">
|
||||
<FormRow>
|
||||
<template #label>Name</template>
|
||||
<TextInput
|
||||
v-model="name"
|
||||
v-koel-focus
|
||||
name="name"
|
||||
placeholder="Playlist name"
|
||||
required
|
||||
title="Playlist name"
|
||||
type="text"
|
||||
>
|
||||
</label>
|
||||
<label class="folder">
|
||||
Folder
|
||||
<select v-model="folderId">
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<template #label>Folder</template>
|
||||
<SelectBox v-model="folderId">
|
||||
<option :value="null" />
|
||||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</SelectBox>
|
||||
</FormRow>
|
||||
</FormRow>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
@ -41,7 +40,10 @@ import { logger } from '@/utils'
|
|||
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||
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 { toastSuccess } = useMessageToaster()
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<span>
|
||||
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
|
||||
<span v-if="justCreatedInviteLink" class="text-secondary copied">
|
||||
<Icon :icon="faCheckCircle" class="text-green" />
|
||||
<Btn v-if="shouldShowInviteButton" success small @click.prevent="inviteCollaborators">Invite</Btn>
|
||||
<span v-if="justCreatedInviteLink" class="text-k-text-secondary text-[0.95rem]">
|
||||
<Icon :icon="faCheckCircle" class="text-k-success mr-1" />
|
||||
Link copied to clipboard!
|
||||
</span>
|
||||
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
|
||||
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-k-success" spin />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
@ -15,7 +15,7 @@ import { computed, ref, toRefs } from 'vue'
|
|||
import { copyText } from '@/utils'
|
||||
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 { playlist } = toRefs(props)
|
||||
|
@ -36,13 +36,3 @@ const inviteCollaborators = async () => {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.copied {
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
<template>
|
||||
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
|
||||
<div class="collaboration-modal max-w-[640px]" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<h1>Playlist Collaboration</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="intro text-secondary">
|
||||
<p class="text-k-text-secondary">
|
||||
Collaborative playlists allow multiple users to contribute. <br>
|
||||
Note: Songs added to a collaborative playlist are made accessible to all users,
|
||||
and you cannot mark a song as private if it’s still part of a collaborative playlist.
|
||||
</p>
|
||||
|
||||
<section class="collaborators">
|
||||
<h2>
|
||||
<span>Current Collaborators</span>
|
||||
<section class="space-y-5">
|
||||
<h2 class="flex text-xl mt-6 mb-1 items-center">
|
||||
<span class="flex-1">Current Collaborators</span>
|
||||
<InviteCollaborators v-if="canManageCollaborators" :playlist="playlist" />
|
||||
</h2>
|
||||
<div v-koel-overflow-fade class="collaborators-wrapper">
|
||||
<div v-koel-overflow-fade class="collaborators-wrapper overflow-auto">
|
||||
<CollaboratorList :playlist="playlist" />
|
||||
</div>
|
||||
</section>
|
||||
|
@ -29,18 +29,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref } from 'vue'
|
||||
import { useAuthorization, useModal, useDialogBox } from '@/composables'
|
||||
import { computed } from 'vue'
|
||||
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 CollaboratorList from '@/components/playlist/PlaylistCollaboratorList.vue'
|
||||
|
||||
const playlist = useModal().getFromContext<Playlist>('playlist')
|
||||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
|
||||
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
|
||||
|
||||
|
@ -49,22 +46,7 @@ const close = () => emit('close')
|
|||
</script>
|
||||
|
||||
<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 {
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<ListSkeleton v-if="loading" />
|
||||
<ul v-else>
|
||||
<ul v-else class="w-full space-y-3">
|
||||
<ListItem
|
||||
is="li"
|
||||
v-for="collaborator in collaborators"
|
||||
:role="collaborator.id === playlist.user_id ? 'owner' : 'contributor'"
|
||||
:key="collaborator.id"
|
||||
:collaborator="collaborator"
|
||||
:manageable="currentUserIsOwner"
|
||||
:removable="currentUserIsOwner && collaborator.id !== playlist.user_id"
|
||||
:collaborator="collaborator"
|
||||
:role="collaborator.id === playlist.user_id ? 'owner' : 'contributor'"
|
||||
@remove="removeCollaborator(collaborator)"
|
||||
/>
|
||||
</ul>
|
||||
|
@ -69,13 +69,3 @@ const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
|
|||
|
||||
onMounted(async () => await fetchCollaborators())
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
<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">
|
||||
<UserAvatar :user="collaborator" width="32" />
|
||||
</span>
|
||||
<span class="name">
|
||||
<span class="flex-1">
|
||||
{{ collaborator.name }}
|
||||
<Icon
|
||||
v-if="collaborator.id === currentUser.id"
|
||||
:icon="faCircleCheck"
|
||||
class="you text-highlight"
|
||||
class="text-k-highlight ml-1"
|
||||
title="This is you!"
|
||||
/>
|
||||
</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-else class="contributor">Contributor</span>
|
||||
</span>
|
||||
<span v-if="manageable" class="actions">
|
||||
<Btn v-if="removable" small red @click.prevent="emit('remove')">Remove</Btn>
|
||||
<span v-if="manageable" class="actions flex-[0_0_72px] text-right">
|
||||
<Btn v-if="removable" small danger @click.prevent="emit('remove')">Remove</Btn>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
@ -26,7 +29,7 @@
|
|||
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
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 { useAuthorization } from '@/composables'
|
||||
|
||||
|
@ -44,57 +47,15 @@ const emit = defineEmits<{ (e: 'remove'): void }>()
|
|||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
li {
|
||||
display: flex;
|
||||
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;
|
||||
span {
|
||||
@apply inline-block min-w-0 leading-normal;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
.role span {
|
||||
@apply px-2 py-1 rounded-md border border-white/20;
|
||||
}
|
||||
|
||||
.you {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
&:only-child .actions:not(:has(button)) {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul>
|
||||
<li v-for="user in displayedCollaborators" :key="user.id">
|
||||
<UserAvatar :user="user" width="24" />
|
||||
<div class="inline-block align-middle">
|
||||
<ul class="align-middle -space-x-2">
|
||||
<li v-for="user in displayedCollaborators" :key="user.id" class="inline-block align-baseline">
|
||||
<UserAvatar :user="user" width="24" class="border border-white/30" />
|
||||
</li>
|
||||
</ul>
|
||||
<span v-if="remainderCount" class="more">
|
||||
<span v-if="remainderCount" class="ml-2">
|
||||
+{{ remainderCount }} more
|
||||
</span>
|
||||
</div>
|
||||
|
@ -22,28 +22,3 @@ const { collaborators } = toRefs(props)
|
|||
const displayedCollaborators = computed(() => collaborators.value.slice(0, 3))
|
||||
const remainderCount = computed(() => collaborators.value.length - displayedCollaborators.value.length)
|
||||
</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>
|
||||
|
|
|
@ -8,27 +8,27 @@
|
|||
<li @click="showCollaborationModal">Collaborate…</li>
|
||||
<li class="separator" />
|
||||
</template>
|
||||
<li v-if="ownedByCurrentUser" @click="edit">Edit…</li>
|
||||
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>
|
||||
<li v-if="canEditPlaylist" @click="edit">Edit…</li>
|
||||
<li v-if="canEditPlaylist" @click="destroy">Delete</li>
|
||||
</ContextMenuBase>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { copyText, eventBus } from '@/utils'
|
||||
import { useAuthorization, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
|
||||
import { playbackService, playlistCollaborationService } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import { usePolicies, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
|
||||
import { playbackService } from '@/services'
|
||||
import { songStore, queueStore } from '@/stores'
|
||||
|
||||
const { base, ContextMenuBase, open, trigger } = useContextMenu()
|
||||
const { go } = useRouter()
|
||||
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||
const { isPlus } = useKoelPlus()
|
||||
const { currentUser } = useAuthorization()
|
||||
const { currentUserCan } = usePolicies()
|
||||
|
||||
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 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
Loading…
Reference in a new issue