feat(ui): use Tailwind CSS

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

View file

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

View file

@ -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);

View file

@ -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) {

View file

@ -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);

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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()));
try {
return $this->cache->remember(
"youtube.$hash",
now()->addWeek(),
fn () => $this->connector->send($request)->object()
);
} catch (Throwable) {
return null;
}
}
}

View file

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

View file

@ -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",

View file

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

View file

@ -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));
&.as-list {
gap: 0.7em 1em;
align-content: start;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
@media only screen and (max-width: 667px) {
display: block;
>*+* {
margin-top: .7rem;
@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));
}
.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;
}
}
.artist-album-info-wrapper {
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.info-wrapper {
color: var(--color-text-secondary);
position: absolute;
top: 0;
left: 0;
background: var(--color-bg-primary);
width: 100%;
height: 100%;
z-index: 2;
.inner {
overflow: auto;
height: 100%;
padding: 24px 24px 48px;
@media only screen and (max-width: 768px) {
padding: 16px;
}
}
.close-modal {
display: none;
}
&:hover {
.close-modal {
display: block;
}
}
}
}
.artist-album-info {
color: var(--color-text-secondary);
h1 {
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-thin);
line-height: 2.8rem;
margin-bottom: 16px;
&.name {
font-size: 2rem;
margin-bottom: 2rem;
}
span {
flex: 1;
margin-right: 12px;
}
a {
font-size: 14px;
&:hover {
color: var(--color-highlight);
}
}
}
.bio,
.wiki {
margin: 16px 0;
}
.more {
margin-top: .75rem;
border-radius: .23rem;
background: var(--color-blue);
color: var(--color-text-primary);
padding: .3rem .6rem;
display: inline-block;
text-transform: uppercase;
font-size: .8rem;
}
.cover,
.cool-guys-posing {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
overflow: hidden;
}
footer {
margin-top: 24px;
font-size: .9rem;
text-align: right;
a {
color: var(--color-text-primary);
font-weight: var(--font-weight-normal);
&:hover {
color: var(--color-text-secondary);
}
}
}
&.full {
.cover {
width: 300px;
max-width: 100%;
float: left;
margin: 0 16px 16px 0;
}
.bio,
.wiki {
margin-top: 0;
}
h1.name {
font-size: 2.4rem;
a.shuffle {
display: none;
}
}
}
}
.inset-when-pressed {
&:not([disabled]):active {
box-shadow: inset 0 10px 10px -10px rgba(0, 0, 0, .6);
}
}
.context-menu {
padding: .4rem 0;
width: max-content;
min-width: 144px;
background-color: var(--color-bg-context-menu);
position: fixed;
border-radius: 4px;
z-index: 1001;
filter: drop-shadow(0 5px 15px rgba(0, 0, 0, .5));
:deep(.arrow) {
background-color: var(--color-bg-context-menu);
position: absolute;
width: 8px;
height: 8px;
transform: rotate(45deg);
}
}
.themed-background, body, html {
background-color: var(--color-bg-primary);
background-image: var(--bg-image);
background-attachment: var(--bg-attachment);
background-size: var(--bg-size);
background-position: var(--bg-position);
}

View file

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

View file

@ -6,9 +6,7 @@
* Make elements draggable in old WebKit
*/
[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;
}
}

View file

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

View file

@ -1,416 +1,54 @@
*,
*::before,
*::after {
box-sizing: border-box;
outline: none;
}
@tailwind base;
h1, h2, h3, h4, h5, h6, blockquote {
text-wrap: balance;
}
@layer base {
h1, h2, h3, h4, h5, h6, blockquote {
@apply text-balance;
}
*::marker {
display: none !important;
}
*::marker {
@apply hidden !important;
}
:root {
:root {
color-scheme: dark;
}
}
::placeholder {
color: rgba(0, 0, 0, .5);
}
::placeholder {
@apply text-black/5;
}
body, html {
@mixin themed-background;
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;
}
@apply text-k-text-primary font-light leading-6 overflow-hidden;
}
input,
select,
button,
textarea,
.btn {
appearance: none;
border: 0;
outline: 0;
input, select, button, textarea, .btn {
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;
}
@apply appearance-none border-0 outline-0 text-base font-light;
&[type="search"] {
border-radius: 12px;
height: 24px;
padding: 0 .5rem;
}
&[type="text"] {
display: block;
}
&[disabled], &[readonly] {
background: rgba(255, 255, 255, .7);
cursor: not-allowed;
}
&:focus {
outline: none !important;
&:required, &:invalid {
@apply shadow-none;
}
&::-moz-focus-inner {
border: 0 !important;
}
}
button,
[role=button] {
cursor: pointer;
background: transparent;
padding: 0;
border: 0;
color: currentColor;
}
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;
}
.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;
}
}
}
.panes {
padding: 1.25rem;
}
}
.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;
@apply border-0 !important;
}
}
li {
list-style: none;
position: relative;
padding: 4px 12px;
cursor: default;
white-space: nowrap;
a {
@apply no-underline text-k-text-primary cursor-pointer hover:text-k-accent focus:text-k-accent;
&: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;
&:link, &:visited {
@apply text-k-accent;
}
}
.submenu {
position: absolute;
display: none;
left: 100%;
top: 0;
}
}
:root {
:root {
--fade-size: 3rem;
}
.fade-top {
-webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
}
.fade-bottom {
-webkit-mask-image: linear-gradient(to top, transparent, black var(--fade-size));
mask-image: linear-gradient(to top, transparent, black var(--fade-size));
}
.fade-top.fade-bottom {
-webkit-mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
-webkit-mask-size: 100% 51%;
-webkit-mask-repeat: no-repeat;
mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
mask-size: 100% 51%;
mask-repeat: no-repeat;
}
}

View file

@ -1,15 +1,15 @@
.skeleton {
.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;
}
}
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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;
}
}

View file

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

View file

@ -4,44 +4,22 @@
* > The included CSS file is pretty minimal... in fact, feel free to scrap it and make your own!
*/
#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;
}
}

View file

@ -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>

View file

@ -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'

View file

@ -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"
>

View file

@ -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.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>
<div v-else v-html="info.wiki.full" />
</template>
<TrackList v-if="info.tracks?.length" :album="album" :tracks="info.tracks" data-testid="album-info-tracks" />
<TrackList
v-if="info.tracks?.length"
:album="album"
:tracks="info.tracks"
data-testid="album-info-tracks"
class="mt-8"
/>
</template>
<footer>
<template v-if="info" #footer>
Data &copy;
<a :href="info.url" rel="noopener" target="_blank">Last.fm</a>
</footer>
</template>
</main>
</article>
</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>

View file

@ -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 {
ul {
counter-reset: trackCounter;
}
}
li {
li {
counter-increment: trackCounter;
display: flex;
padding: 8px;
&::before {
content: counter(trackCounter);
flex: 0 0 24px;
opacity: .5;
}
&:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
}
}
</style>

View file

@ -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;
}
}
}

View file

@ -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>
`;

View file

@ -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>

View file

@ -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>
`;

View file

@ -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>

View file

@ -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>
<template v-if="info" #footer>
Data &copy;
<a :href="info.url" rel="openener" target="_blank">Last.fm</a>
</footer>
</template>
</main>
</article>
</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>

View file

@ -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>
`;

View file

@ -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>

View file

@ -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>
<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>

View file

@ -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>
<FormRow>
<PasswordField v-model="password" placeholder="Password" required />
</FormRow>
<FormRow>
<Btn type="submit">Log In</Btn>
<a
v-if="canResetPassword"
class="reset-password"
role="button"
@click.prevent="showForgotPasswordForm"
>
</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>

View file

@ -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>
<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">Min. 10 characters. Should be a mix of characters, numbers, and symbols.</span>
<span class="help block mt-4">Min. 10 characters. Should be a mix of characters, numbers, and symbols.</span>
</label>
</div>
<div>
<Btn :disabled="loading" type="submit">Save</Btn>
</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>

View file

@ -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>
`;

View file

@ -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>

View file

@ -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
<FormRow>
<template #label>Your name</template>
<TextInput
v-model="name"
v-koel-focus
data-testid="name"
placeholder="Erm… Bruce Dickinson?"
required
type="text"
>
</label>
</div>
/>
</FormRow>
<div class="form-row">
<label>
Password
<FormRow>
<template #label>Password</template>
<PasswordField v-model="password" data-testid="password" required />
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small>
</label>
</div>
<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 &amp; 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -2,63 +2,42 @@
<!--
A very thin wrapper around Plyr, extracted as a standalone component for easier styling and to work better with HMR.
-->
<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);
}
@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;
}
}
}

View file

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

View file

@ -1,38 +1,30 @@
<template>
<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;
}
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>
`;

View file

@ -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;
}
}
}

View file

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

View file

@ -1,5 +1,8 @@
<template>
<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>

View file

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

View file

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

View file

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

View file

@ -1,45 +1,41 @@
<template>
<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>

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<template>
<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>

View file

@ -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">
<ul>
<SidebarItem @click="toggle" @contextmenu.prevent="onContextMenu">
<template #icon>
<Icon :icon="opened ? faFolderOpen : faFolder" fixed-width />
<span>{{ folder.name }}</span>
</a>
</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>
<div
<li
v-if="opened"
:class="droppableOnHatch && 'droppable'"
class="hatch"
class="hatch absolute bottom-0 w-full h-1"
@dragover="onDragOverHatch"
@dragleave.prevent="onDragLeaveHatch"
@drop.prevent="onDropOnHatch"
/>
</ul>
</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>

View file

@ -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>

View file

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

View file

@ -2,12 +2,14 @@
<SidebarItem
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>

View file

@ -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'

View file

@ -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 v-koel-overflow-fade class="py-0 px-6 overflow-y-auto space-y-8">
<SidebarYourMusicSection />
<SidebarPlaylistsSection />
<SidebarManageSection v-if="showManageSection" />
</section>
<PlaylistList />
<section v-if="showManageSection" class="manage">
<h1>Manage</h1>
<ul class="menu">
<SidebarItem v-if="isAdmin" screen="Settings" href="#/settings" :icon="faTools">Settings</SidebarItem>
<SidebarItem screen="Upload" href="#/upload" :icon="faUpload">Upload</SidebarItem>
<SidebarItem v-if="isAdmin" screen="Users" href="#/users" :icon="faUsers">Users</SidebarItem>
</ul>
</section>
</section>
<section 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>

View file

@ -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 { 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>

View file

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

View file

@ -3,13 +3,13 @@ import { it } from 'vitest'
import { playlistFolderStore, playlistStore } from '@/stores'
import 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,14 @@
<template>
<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>

View file

@ -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>`;

View file

@ -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>`;

View file

@ -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 }} &lt;{{ license.customerEmail }}&gt;
<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>
@ -39,15 +45,7 @@
>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>

View file

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

View file

@ -1,36 +1,26 @@
<template>
<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>

View file

@ -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>

View file

@ -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>
`;

View file

@ -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 () {

View file

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

View file

@ -5,16 +5,15 @@
</header>
<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()

View file

@ -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>

View file

@ -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()

View file

@ -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()

View file

@ -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>

View file

@ -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 its 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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