feat: invite users

This commit is contained in:
Phan An 2023-08-21 00:35:58 +02:00
parent c382d5799e
commit f87d970b50
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
100 changed files with 1957 additions and 728 deletions

View file

@ -140,14 +140,16 @@ PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=
# The following settings are for Koel to send emails, for example to send user invitations and reset passwords.
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SQS_PUBLIC_KEY=
SQS_SECRET_KEY=

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class InvalidCredentialsException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class InvitationNotFoundException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use LogicException;
class UserProspectUpdateDeniedException extends LogicException
{
}

View file

@ -2,14 +2,11 @@
namespace App\Http\Controllers\API;
use App\Exceptions\InvalidCredentialsException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserLoginRequest;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Services\TokenManager;
use Illuminate\Contracts\Auth\Authenticatable;
use App\Services\AuthenticationService;
use Illuminate\Foundation\Auth\ThrottlesLogins;
use Illuminate\Hashing\HashManager;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -17,38 +14,37 @@ class AuthController extends Controller
{
use ThrottlesLogins;
/** @param User $user */
public function __construct(
private UserRepository $userRepository,
private HashManager $hash,
private TokenManager $tokenManager,
private ?Authenticatable $user
) {
public function __construct(private AuthenticationService $auth)
{
}
public function login(UserLoginRequest $request)
{
/** @var User|null $user */
$user = $this->userRepository->getFirstWhere('email', $request->email);
if (!$user || !$this->hash->check($request->password, $user->password)) {
abort(Response::HTTP_UNAUTHORIZED, 'Invalid credentials');
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
$this->sendLockoutResponse($request);
}
$token = $this->tokenManager->createCompositionToken($user);
return response()->json([
'token' => $token->apiToken,
'audio-token' => $token->audioToken,
]);
try {
return response()->json($this->auth->login($request->email, $request->password)->toArray());
} catch (InvalidCredentialsException) {
$this->incrementLoginAttempts($request);
abort(Response::HTTP_UNAUTHORIZED, 'Invalid credentials');
}
}
public function logout(Request $request)
{
if ($this->user) {
attempt(fn () => $this->tokenManager->deleteCompositionToken($request->bearerToken()));
}
attempt(fn () => $this->auth->logoutViaBearerToken($request->bearerToken()));
return response()->noContent();
}
/**
* For the throttle middleware.
*/
protected function username(): string
{
return 'email';
}
}

View file

@ -50,6 +50,8 @@ class SongController extends Controller
public function update(SongUpdateRequest $request)
{
$this->authorize('admin', $this->user);
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
$albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray());

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Exceptions\UserProspectUpdateDeniedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserStoreRequest;
use App\Http\Requests\API\UserUpdateRequest;
@ -9,6 +10,7 @@ use App\Http\Resources\UserResource;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Services\UserService;
use Illuminate\Http\Response;
class UserController extends Controller
{
@ -39,13 +41,17 @@ class UserController extends Controller
{
$this->authorize('admin', User::class);
return UserResource::make($this->userService->updateUser(
$user,
$request->name,
$request->email,
$request->password,
$request->get('is_admin') ?: false
));
try {
return UserResource::make($this->userService->updateUser(
$user,
$request->name,
$request->email,
$request->password,
$request->get('is_admin') ?: false
));
} catch (UserProspectUpdateDeniedException) {
abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.');
}
}
public function destroy(User $user)

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\API;
use App\Exceptions\InvitationNotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\AcceptUserInvitationRequest;
use App\Http\Requests\API\GetUserInvitationRequest;
use App\Http\Requests\API\InviteUserRequest;
use App\Http\Requests\API\RevokeUserInvitationRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\AuthenticationService;
use App\Services\UserInvitationService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class UserInvitationController extends Controller
{
/**
* @param User $invitor
*/
public function __construct(
private UserInvitationService $invitationService,
private AuthenticationService $auth,
private ?Authenticatable $invitor
) {
}
public function invite(InviteUserRequest $request)
{
$this->authorize('admin', $this->invitor);
$invitees = $this->invitationService->invite(
$request->emails,
$request->get('is_admin') ?: false,
$this->invitor
);
return UserResource::collection($invitees);
}
public function get(GetUserInvitationRequest $request)
{
try {
return UserResource::make($this->invitationService->getUserProspectByToken($request->token));
} catch (InvitationNotFoundException) {
abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.');
}
}
public function accept(AcceptUserInvitationRequest $request)
{
try {
$user = $this->invitationService->accept($request->token, $request->name, $request->password);
return response()->json($this->auth->login($user->email, $request->password)->toArray());
} catch (InvitationNotFoundException) {
abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.');
}
}
public function revoke(RevokeUserInvitationRequest $request)
{
$this->authorize('admin', $this->invitor);
try {
$this->invitationService->revokeByEmail($request->email);
return response()->noContent();
} catch (InvitationNotFoundException) {
abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.');
}
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\API;
use Illuminate\Validation\Rules\Password;
/**
* @property-read string $token
* @property-read string $name
* @property-read string $password
*/
class AcceptUserInvitationRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'name' => 'required',
'token' => 'required',
'password' => ['required', Password::defaults()],
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\API;
/**
* @property-read string $token
*/
class GetUserInvitationRequest extends Request
{
/**
* @return array<mixed>
*/
public function rules(): array
{
return [
'token' => 'required|string',
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\API;
/**
* @property-read array<string> $emails
*/
class InviteUserRequest extends Request
{
/**
* @return array<mixed>
*/
public function rules(): array
{
return [
'emails.*' => 'required|email|unique:users,email',
'is_admin' => 'sometimes',
];
}
/**
* @return array<mixed>
*/
public function messages(): array
{
return [
'emails.*.unique' => 'The email :input is already registered.',
];
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests\API;
/**
* @property-read string $email
*/
class RevokeUserInvitationRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return ['email' => 'required|email'];
}
}

View file

@ -8,11 +8,6 @@ namespace App\Http\Requests\API;
*/
class SongUpdateRequest extends Request
{
public function authorize(): bool
{
return $this->user()->is_admin;
}
/** @return array<mixed> */
public function rules(): array
{

View file

@ -23,6 +23,7 @@ class UserResource extends JsonResource
'avatar' => $this->user->avatar,
'is_admin' => $this->user->is_admin,
'preferences' => $this->when($this->includePreferences, $this->user->preferences),
'is_prospect' => $this->user->is_prospect,
];
}
}

36
app/Mail/UserInvite.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UserInvite extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(private User $invitee)
{
}
public function content(): Content
{
return new Content(
markdown: 'emails.users.invite',
with: [
'invitee' => $this->invitee,
'url' => url("/#/invitation/accept/{$this->invitee->invitation_token}"),
],
);
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Invitation to join Koel');
}
}

View file

@ -4,9 +4,11 @@ namespace App\Models;
use App\Casts\UserPreferencesCast;
use App\Values\UserPreferences;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -25,6 +27,11 @@ use Laravel\Sanctum\PersonalAccessToken;
* @property Collection|array<array-key, Playlist> $playlists
* @property Collection|array<array-key, PlaylistFolder> $playlist_folders
* @property PersonalAccessToken $currentAccessToken
* @property ?Carbon $invitation_accepted_at
* @property ?User $invitedBy
* @property ?string $invitation_token
* @property ?Carbon $invited_at
* @property-read bool $is_prospect
*/
class User extends Authenticatable
{
@ -33,7 +40,7 @@ class User extends Authenticatable
use Notifiable;
protected $guarded = ['id'];
protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at'];
protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at', 'invitation_accepted_at'];
protected $appends = ['avatar'];
protected $casts = [
@ -41,6 +48,11 @@ class User extends Authenticatable
'preferences' => UserPreferencesCast::class,
];
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_id');
}
public function playlists(): HasMany
{
return $this->hasMany(Playlist::class);
@ -71,6 +83,11 @@ class User extends Authenticatable
return Attribute::get(fn (): ?string => $this->preferences->lastFmSessionKey);
}
protected function isProspect(): Attribute
{
return Attribute::get(fn (): bool => (bool) $this->invitation_token);
}
/**
* Determine if the user is connected to Last.fm.
*/

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services;
use App\Exceptions\InvalidCredentialsException;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Values\CompositionToken;
use Illuminate\Hashing\HashManager;
class AuthenticationService
{
public function __construct(
private UserRepository $userRepository,
private TokenManager $tokenManager,
private HashManager $hash
) {
}
public function login(string $email, string $password): CompositionToken
{
/** @var User|null $user */
$user = $this->userRepository->getFirstWhere('email', $email);
if (!$user || !$this->hash->check($password, $user->password)) {
throw new InvalidCredentialsException();
}
return $this->tokenManager->createCompositionToken($user);
}
public function logoutViaBearerToken(string $token): void
{
$this->tokenManager->deleteCompositionToken($token);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Exceptions\InvitationNotFoundException;
use App\Mail\UserInvite;
use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher as Hash;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class UserInvitationService
{
public function __construct(private Hash $hash)
{
}
/** @return Collection|array<array-key, User> */
public function invite(array $emails, bool $isAdmin, User $invitor): Collection
{
return DB::transaction(function () use ($emails, $isAdmin, $invitor) {
return collect($emails)->map(fn ($email) => $this->inviteOne($email, $isAdmin, $invitor));
});
}
/** @throws InvitationNotFoundException */
public function getUserProspectByToken(string $token): User
{
return User::query()->where('invitation_token', $token)->firstOr(static function (): void {
throw new InvitationNotFoundException();
});
}
/** @throws InvitationNotFoundException */
public function revokeByEmail(string $email): void
{
/** @var ?User $user */
$user = User::query()->where('email', $email)->first();
throw_unless($user?->is_prospect, new InvitationNotFoundException());
$user->delete();
}
private function inviteOne(string $email, bool $isAdmin, User $invitor): User
{
/** @var User $invitee */
$invitee = User::query()->create([
'name' => '',
'email' => $email,
'password' => '',
'is_admin' => $isAdmin,
'invited_by_id' => $invitor->id,
'invitation_token' => Str::uuid()->toString(),
'invited_at' => now(),
]);
Mail::to($email)->queue(new UserInvite($invitee));
return $invitee;
}
/** @throws InvitationNotFoundException */
public function accept(string $token, string $name, string $password): User
{
$user = $this->getUserProspectByToken($token);
$user->update([
'name' => $name,
'password' => $this->hash->make($password),
'invitation_token' => null,
'invitation_accepted_at' => now(),
]);
return $user;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Exceptions\UserProspectUpdateDeniedException;
use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher;
@ -23,6 +24,8 @@ class UserService
public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User
{
throw_if($user->is_prospect, new UserProspectUpdateDeniedException());
$data = [
'name' => $name,
'email' => $email,

View file

@ -2,6 +2,7 @@
namespace App\Values;
use Illuminate\Contracts\Support\Arrayable;
use Laravel\Sanctum\NewAccessToken;
/**
@ -13,7 +14,7 @@ use Laravel\Sanctum\NewAccessToken;
*
* This approach helps prevent the API token from being logged by servers and proxies.
*/
final class CompositionToken
final class CompositionToken implements Arrayable
{
private function __construct(public string $apiToken, public string $audioToken)
{
@ -23,4 +24,12 @@ final class CompositionToken
{
return new self($api->plainTextToken, $audio->plainTextToken);
}
public function toArray(): array
{
return [
'token' => $this->apiToken,
'audio-token' => $this->audioToken,
];
}
}

View file

@ -48,8 +48,8 @@ return [
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
'address' => env('MAIL_FROM_ADDRESS', 'noreply@koel.local'),
'name' => env('MAIL_FROM_NAME', 'Koel'),
],
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->string('invitation_token', 36)->nullable()->index();
$table->timestamp('invited_at')->nullable();
$table->timestamp('invitation_accepted_at')->nullable();
$table->unsignedInteger('invited_by_id')->nullable();
$table->foreign('invited_by_id')->references('id')->on('users')->nullOnDelete();
});
}
};

View file

@ -33,7 +33,7 @@
"slugify": "^1.0.2",
"three": "^0.146.0",
"tiny-typed-emitter": "^2.1.0",
"vue": "^3.2.32",
"vue": "^3.3.4",
"vue-global-events": "^2.1.1",
"youtube-player": "^3.0.4"
},

View file

@ -5,7 +5,7 @@
<GlobalEventListeners />
<OfflineNotification v-if="offline" />
<div v-if="authenticated" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
<div v-if="layout === 'main'" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
<Hotkeys />
<MainWrapper />
<AppFooter />
@ -19,9 +19,9 @@
<DropZone v-show="showDropZone" />
</div>
<div v-else class="login-wrapper">
<LoginForm @loggedin="onUserLoggedIn" />
</div>
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
<AcceptInvitation v-if="layout === 'invitation'" />
</template>
<script lang="ts" setup>
@ -54,15 +54,17 @@ const SongContextMenu = defineAsyncComponent(() => import('@/components/song/Son
const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/components/playlist/CreateNewPlaylistContextMenu.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'))
const overlay = ref<InstanceType<typeof Overlay>>()
const dialog = ref<InstanceType<typeof DialogBox>>()
const toaster = ref<InstanceType<typeof MessageToaster>>()
const currentSong = ref<Song>()
const authenticated = ref(false)
const showDropZone = ref(false)
const { isCurrentScreen } = useRouter()
const layout = ref<'main' | 'auth' | 'invitation'>()
const { isCurrentScreen, resolveRoute } = useRouter()
const { offline } = useNetworkStatus()
/**
@ -75,15 +77,21 @@ const requestNotificationPermission = async () => {
}
const onUserLoggedIn = async () => {
authenticated.value = true
layout.value = 'main'
await init()
}
onMounted(async () => {
// The app has just been initialized, check if we can get the user data with an already existing token
if (authService.hasApiToken()) {
authenticated.value = true
await init()
// call resolveRoute() after init() so that the onResolve hooks can use the stores
await resolveRoute()
layout.value = 'main'
} else {
await resolveRoute()
layout.value = isCurrentScreen('Invitation.Accept') ? 'invitation' : 'auth'
}
// Add an ugly mac/non-mac class for OS-targeting styles.
@ -110,7 +118,7 @@ const init = async () => {
await socketService.init() && socketListener.listen()
overlay.value!.hide()
} catch (err) {
authenticated.value = false
layout.value = 'auth'
throw err
}
}
@ -164,7 +172,7 @@ provide(CurrentSongKey, currentSong)
}
}
#main, .login-wrapper {
#main {
display: flex;
height: 100vh;
flex-direction: column;
@ -180,10 +188,4 @@ provide(CurrentSongKey, currentSong)
padding-top: var(--header-height);
}
}
.login-wrapper {
@include vertical-center();
user-select: none;
padding-bottom: 0;
}
</style>

View file

@ -6,6 +6,7 @@ export default (faker: Faker): User => ({
name: faker.name.findName(),
email: faker.internet.email(),
password: faker.internet.password(),
is_prospect: false,
is_admin: false,
avatar: 'https://gravatar.com/foo',
preferences: {}
@ -14,5 +15,8 @@ export default (faker: Faker): User => ({
export const states: Record<string, Omit<Partial<User>, 'type'>> = {
admin: {
is_admin: true
},
prospect: {
is_prospect: true
}
}

View file

@ -1,10 +1,10 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article class="item full" draggable="true" tabindex="0" title="IV by Led Zeppelin" data-v-f01bdc56=""><br data-testid="thumbnail" entity="[object Object]" data-v-f01bdc56="">
<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 class="name" data-v-f01bdc56=""><a href="#/album/42" class="text-normal" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
<p class="meta" data-v-f01bdc56=""><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>
<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>
</footer>
</article>
`;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu" data-v-0408531a="">
<nav data-v-0408531a="" class="album-menu menu context-menu" 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 class="track-list-item" title="" tabindex="0" data-v-da281390=""><span class="title" data-v-da281390="">Fahrstuhl to Heaven</span>
<!----><span class="length" data-v-da281390="">04:40</span>
<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>
`;

View file

@ -1,10 +1,10 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article class="item full" draggable="true" tabindex="0" title="Led Zeppelin" data-v-f01bdc56=""><br data-testid="thumbnail" entity="[object Object]" data-v-f01bdc56="">
<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 class="name" data-v-f01bdc56=""><a href="#/artist/42" class="text-normal" data-testid="name">Led Zeppelin</a></div>
<p class="meta" data-v-f01bdc56=""><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>
<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>
</footer>
</article>
`;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu" data-v-0408531a="">
<nav data-v-0408531a="" class="artist-menu menu context-menu" 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,14 @@
<template>
<form :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>
<input v-model="email" autofocus placeholder="Email Address" required type="email">
<input v-model="password" placeholder="Password" required type="password">
<Btn type="submit">Log In</Btn>
</form>
<div class="login-wrapper">
<form :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>
<input v-model="email" autofocus placeholder="Email Address" required type="email">
<PasswordField v-model="password" placeholder="Password" required />
<Btn type="submit">Log In</Btn>
</form>
</div>
</template>
<script lang="ts" setup>
@ -15,6 +17,7 @@ import { userStore } from '@/stores'
import { isDemo } from '@/utils'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
const DEMO_ACCOUNT = {
email: 'demo@koel.dev',
@ -69,6 +72,15 @@ const login = async () => {
}
}
.login-wrapper {
@include vertical-center();
display: flex;
height: 100vh;
flex-direction: column;
justify-content: center;
}
form {
width: 280px;
padding: 1.8rem;

View file

@ -1,7 +1,10 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<form class="" data-testid="login-form" data-v-0b0f87ea="">
<div class="logo" data-v-0b0f87ea=""><img alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156" data-v-0b0f87ea=""></div><input autofocus="" placeholder="Email Address" required="" type="email" data-v-0b0f87ea=""><input placeholder="Password" required="" type="password" data-v-0b0f87ea=""><button type="submit" data-v-e368fe26="" data-v-0b0f87ea="">Log In</button>
</form>
<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="" type="submit">Log In</button>
</form>
</div>
`;

View file

@ -0,0 +1,126 @@
<template>
<div class="invitation-wrapper">
<form v-if="userProspect" autocomplete="off" @submit.prevent="submit">
<header>
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>
<div class="form-row">
<label>
Your name
<input v-model="formData.name" v-koel-focus type="text" required placeholder="Erm… Bruce Dickinson?">
</label>
</div>
<div class="form-row">
<label>
Password
<PasswordField v-model="formData.password" minlength="10" />
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small>
</label>
</div>
<div class="form-row">
<Btn type="submit">Accept &amp; Log In</Btn>
</div>
</form>
<p v-if="!validToken">Invalid or expired invite.</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, 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'
const { showErrorDialog } = useDialogBox()
const { getRouteParam, go } = useRouter()
const userProspect = ref<User>()
const validToken = ref(true)
const formData = reactive<{ name: string, password: string }>({
name: '',
password: ''
})
const token = String(getRouteParam('token')!)
const submit = async () => {
try {
await invitationService.accept(token, formData.name, formData.password)
window.location.href = '/'
} catch (err: any) {
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
showErrorDialog(msg, 'Error')
}
}
onMounted(async () => {
try {
userProspect.value = await invitationService.getUserProspect(token)
} catch (err: any) {
if (err.response.status === 404) {
validToken.value = false
return
}
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
showErrorDialog(msg, 'Error')
}
})
</script>
<style scoped lang="scss">
.invitation-wrapper {
@include vertical-center();
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: 414px) {
border: 0;
background: transparent;
}
}
</style>

View file

@ -10,6 +10,7 @@ new class extends UnitTestCase {
protected test () {
it.each<[string, keyof Events, User | Song[] | Playlist | PlaylistFolder | undefined]>([
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined],
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory<User>('user')],
['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory<Song>('song')]],
['create-playlist-form', 'MODAL_SHOW_CREATE_PLAYLIST_FORM', factory<PlaylistFolder>('playlist-folder')],
@ -25,6 +26,7 @@ new class extends UnitTestCase {
stubs: {
AddUserForm: this.stub('add-user-form'),
EditUserForm: this.stub('edit-user-form'),
InviteUserForm: this.stub('invite-user-form'),
EditSongForm: this.stub('edit-song-form'),
CreatePlaylistForm: this.stub('create-playlist-form'),
CreatePlaylistFolderForm: this.stub('create-playlist-folder-form'),

View file

@ -16,6 +16,7 @@ const modalNameToComponentMap = {
'edit-smart-playlist-form': defineAsyncComponent(() => import('@/components/playlist/smart-playlist/EditSmartPlaylistForm.vue')),
'add-user-form': defineAsyncComponent(() => import('@/components/user/AddUserForm.vue')),
'edit-user-form': defineAsyncComponent(() => import('@/components/user/EditUserForm.vue')),
'invite-user-form': defineAsyncComponent(() => import('@/components/user/InviteUserForm.vue')),
'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')),
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
@ -40,6 +41,7 @@ const close = () => {
eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'))
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
.on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form'))
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => {
context.value = {
folder,

View file

@ -1,9 +1,9 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
<div class="wrapper" data-v-8bf5fe81=""><a class="visualizer-btn" data-testid="toggle-visualizer-btn" href="/#/visualizer" title="Show the visualizer" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></a>
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
<div data-v-8bf5fe81="" class="extra-controls" data-testid="other-controls">
<div data-v-8bf5fe81="" class="wrapper"><a data-v-8bf5fe81="" class="visualizer-btn" data-testid="toggle-visualizer-btn" href="/#/visualizer" title="Show the visualizer"><br data-v-8bf5fe81="" data-testid="icon" icon="[object Object]"></a>
<!--v-if--><br data-v-8bf5fe81="" data-testid="Volume">
<!--v-if-->
</div>
</div>

View file

@ -1,9 +1,9 @@
// Vitest Snapshot v1
exports[`renders with a current song 1`] = `
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
<div class="buttons" data-v-2e8b419d=""><button title="Unlike Fahrstuhl to Heaven by Led Zeppelin" type="button" class="like-btn" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]"></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
<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">
<div data-v-cab48a7c="" class="fa-layers"><br data-v-cab48a7c="" data-testid="icon" icon="[object Object]">
<!--v-if-->
</div>
</button></div>
@ -11,9 +11,9 @@ exports[`renders with a current song 1`] = `
`;
exports[`renders without a current song 1`] = `
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
<div class="buttons" data-v-2e8b419d=""><button type="button" data-v-2e8b419d=""></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
<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">
<div data-v-cab48a7c="" class="fa-layers"><br data-v-cab48a7c="" data-testid="icon" icon="[object Object]">
<!--v-if-->
</div>
</button></div>

View file

@ -1,15 +1,15 @@
// Vitest Snapshot v1
exports[`renders with current song 1`] = `
<div class="song-info playing" data-v-91ed60f7=""><span style="background-image: url(https://via.placeholder.com/150);" class="album-thumb" data-v-91ed60f7=""></span>
<div class="meta" data-v-91ed60f7="">
<h3 class="title" data-v-91ed60f7="">Fahrstuhl zum Mond</h3><a href="/#/artist/10" class="artist" data-v-91ed60f7="">Led Zeppelin</a>
<div data-v-91ed60f7="" class="song-info playing"><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>
</div>
`;
exports[`renders with no current song 1`] = `
<div class="song-info" data-v-91ed60f7=""><span style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="album-thumb" data-v-91ed60f7=""></span>
<div data-v-91ed60f7="" class="song-info"><span data-v-91ed60f7="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="album-thumb"></span>
<!--v-if-->
</div>
`;

View file

@ -64,16 +64,18 @@ const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/N
const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens/VisualizerScreen.vue'))
const { useYouTube } = useThirdPartyServices()
const { resolveRoute, onRouteChanged } = useRouter()
const { resolveRoute, onRouteChanged, getCurrentScreen } = useRouter()
const currentSong = requireInjection(CurrentSongKey, ref(null))
const currentSong = requireInjection(CurrentSongKey, ref(undefined))
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
const screen = ref<ScreenName>('Home')
onRouteChanged(route => (screen.value = route.screen))
onMounted(() => resolveRoute())
onMounted(async () => {
screen.value = getCurrentScreen()
})
</script>
<style lang="scss">

View file

@ -1,14 +1,14 @@
// Vitest Snapshot v1
exports[`renders without a current song 1`] = `
<aside class="" data-v-119c862c="">
<div class="controls" data-v-119c862c="">
<div class="top" data-v-119c862c=""><button class="burger" data-v-119c862c=""><br data-testid="icon" icon="[object Object]"></button>
<aside data-v-119c862c="" class="">
<div data-v-119c862c="" class="controls">
<div data-v-119c862c="" class="top"><button data-v-119c862c="" class="burger"><br data-testid="icon" icon="[object Object]"></button>
<!--v-if-->
</div>
<div class="bottom" data-v-119c862c=""><button title="About Koel" type="button" data-v-119c862c=""><br data-testid="icon" icon="[object Object]" data-v-119c862c="">
<div data-v-119c862c="" class="bottom"><button data-v-119c862c="" title="About Koel" type="button"><br data-v-119c862c="" data-testid="icon" icon="[object Object]">
<!--v-if-->
</button><button title="Log out" type="button" data-v-119c862c=""><br data-testid="icon" icon="[object Object]" data-v-119c862c=""></button><br data-testid="stub" data-v-119c862c=""></div>
</button><button data-v-119c862c="" title="Log out" type="button"><br data-v-119c862c="" data-testid="icon" icon="[object Object]"></button><br data-v-119c862c="" data-testid="stub"></div>
</div>
<!--v-if-->
</aside>

View file

@ -18,7 +18,7 @@ new class extends UnitTestCase {
})
}
protected test() {
protected test () {
it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('activates when the screen matches', async () => {
@ -26,7 +26,7 @@ new class extends UnitTestCase {
await this.router.activateRoute({
screen: 'Home',
path: '_',
path: '_'
})
expect(screen.getByRole('link').classList.contains('active')).toBe(true)

View file

@ -1,15 +1,15 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="about text-secondary" data-testid="about-koel" tabindex="0" data-v-6b5b01a9="">
<div data-v-6b5b01a9="" class="about text-secondary" data-testid="about-koel" tabindex="0">
<main data-v-6b5b01a9="">
<div class="logo" data-v-6b5b01a9=""><img alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128" data-v-6b5b01a9=""></div>
<p class="current-version" data-v-6b5b01a9="">Koel v0.0.0</p>
<div data-v-6b5b01a9="" class="logo"><img data-v-6b5b01a9="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128"></div>
<p data-v-6b5b01a9="" class="current-version">Koel v0.0.0</p>
<!--v-if-->
<p class="author" data-v-6b5b01a9=""> Made with ❤️ by <a href="https://github.com/phanan" rel="noopener" target="_blank" data-v-6b5b01a9="">Phan An</a> and quite a few <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">awesome</a>&nbsp;<a href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">contributors</a>. </p>
<!--v-if--><br data-testid="sponsor-list" data-v-6b5b01a9="">
<p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank" data-v-6b5b01a9="">GitHub Sponsors</a> and/or <a href="https://opencollective.com/koel" rel="noopener" target="_blank" data-v-6b5b01a9="">OpenCollective</a>. </p>
<p data-v-6b5b01a9="" class="author"> Made with ❤️ by <a data-v-6b5b01a9="" href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a> and quite a few <a data-v-6b5b01a9="" href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a data-v-6b5b01a9="" href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank">contributors</a>. </p>
<!--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 type="button" data-testid="close-modal-btn" red="" rounded="" data-v-e368fe26="" data-v-6b5b01a9="">Close</button></footer>
<footer data-v-6b5b01a9=""><button data-v-e368fe26="" data-v-6b5b01a9="" data-testid="close-modal-btn" red="" rounded="">Close</button></footer>
</div>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<div class="sponsors" data-v-46e7cf66=""><a href="https://render.com" title="Render - Cloud Hosting for Developers" target="_blank" data-v-46e7cf66=""><img alt="Render - Cloud Hosting for Developers" src="undefined/resources/assets/img/sponsors/render.svg" style="height: 28px;" data-v-46e7cf66=""></a><a href="https://www.keycdn.com?a=11519" title="KeyCDN - Content delivery made easy" target="_blank" data-v-46e7cf66=""><img alt="KeyCDN - Content delivery made easy" src="undefined/resources/assets/img/sponsors/keycdn.svg" data-v-46e7cf66=""></a><a href="https://whatthediff.ai" title="What The Diff - AI powered changelog generation" target="_blank" data-v-46e7cf66=""><img alt="What The Diff - AI powered changelog generation" src="undefined/resources/assets/img/sponsors/what-the-diff.svg" style="height: 20px;" data-v-46e7cf66=""></a></div>`;
exports[`renders 1`] = `<div data-v-46e7cf66="" class="sponsors"><a data-v-46e7cf66="" href="https://render.com" title="Render - Cloud Hosting for Developers" target="_blank"><img data-v-46e7cf66="" alt="Render - Cloud Hosting for Developers" src="undefined/resources/assets/img/sponsors/render.svg" style="height: 28px;"></a><a data-v-46e7cf66="" href="https://www.keycdn.com?a=11519" title="KeyCDN - Content delivery made easy" target="_blank"><img data-v-46e7cf66="" alt="KeyCDN - Content delivery made easy" src="undefined/resources/assets/img/sponsors/keycdn.svg"></a><a data-v-46e7cf66="" href="https://whatthediff.ai" title="What The Diff - AI powered changelog generation" target="_blank"><img data-v-46e7cf66="" alt="What The Diff - AI powered changelog generation" src="undefined/resources/assets/img/sponsors/what-the-diff.svg" style="height: 20px;"></a></div>`;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`shows after a delay 1`] = `
<div class="support-bar" data-testid="support-bar" data-v-c8ee2518="">
<p data-v-c8ee2518=""> Loving Koel? Please consider supporting its development via <a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank" data-v-c8ee2518="">GitHub Sponsors</a> and/or <a href="https://opencollective.com/koel" rel="noopener" target="_blank" data-v-c8ee2518="">OpenCollective</a>. </p><button type="button" data-v-c8ee2518="">Hide</button><span class="sep" data-v-c8ee2518=""></span><button type="button" data-v-c8ee2518=""> Don't bug me again </button>
<div data-v-c8ee2518="" class="support-bar" data-testid="support-bar">
<p data-v-c8ee2518=""> Loving Koel? Please consider supporting its development via <a data-v-c8ee2518="" href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank">GitHub Sponsors</a> and/or <a data-v-c8ee2518="" href="https://opencollective.com/koel" rel="noopener" target="_blank">OpenCollective</a>. </p><button data-v-c8ee2518="" type="button">Hide</button><span data-v-c8ee2518="" class="sep"></span><button data-v-c8ee2518="" type="button"> Don't bug me again </button>
</div>
`;

View file

@ -31,14 +31,11 @@
<div class="form-row">
<label>
New Password
<input
id="inputProfileNewPassword"
<PasswordField
placeholder="Leave empty to keep current password"
v-model="profile.new_password"
autocomplete="new-password"
name="new_password"
placeholder="Leave empty to keep current password"
type="password"
>
/>
<span class="password-rules help">
Min. 10 characters. Should be a mix of characters, numbers, and symbols.
</span>
@ -61,6 +58,7 @@ import { isDemo, logger, parseValidationError } from '@/utils'
import { useDialogBox, useMessageToaster } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
const { toastSuccess } = useMessageToaster()
const { showErrorDialog } = useDialogBox()
@ -75,6 +73,8 @@ onMounted(() => {
})
const update = async () => {
console.log(profile.value)
return
if (!profile.value) {
throw Error()
}
@ -98,9 +98,11 @@ const update = async () => {
</script>
<style lang="scss" scoped>
input {
&[type="text"], &[type="email"], &[type="password"] {
width: 33%;
form {
width: 33%;
input {
width: 100%;
}
}

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="theme" style="background-color: rgb(255, 0, 0);" title="Set current theme to Sample" role="button" data-v-1467c50f="">
<div class="name" data-v-1467c50f="">Sample</div>
<div data-v-1467c50f="" class="theme" style="background-color: rgb(255, 0, 0);" title="Set current theme to Sample" role="button">
<div data-v-1467c50f="" class="name">Sample</div>
</div>
`;

View file

@ -88,7 +88,7 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
@ -108,7 +108,7 @@ const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCar
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
const { showErrorDialog } = useDialogBox()
const { getRouteParam, go, onRouteChanged } = useRouter()
const { getRouteParam, go, onScreenActivated } = useRouter()
const albumId = ref<number>()
const album = ref<Album>()
@ -152,7 +152,7 @@ watch(activeTab, async tab => {
})
watch(albumId, async id => {
if (!id) return
if (!id || loading.value) return
album.value = undefined
info.value = undefined
@ -176,9 +176,7 @@ watch(albumId, async id => {
}
})
onMounted(async () => (albumId.value = parseInt(getRouteParam('id')!)))
onRouteChanged(route => route.screen === 'Album' && (albumId.value = parseInt(getRouteParam('id')!)))
onScreenActivated('Album', () => (albumId.value = parseInt(getRouteParam('id')!)))
// if the current album has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => albumStore.byId(albumId.value!) || go('albums'))

View file

@ -1,5 +1,5 @@
<template>
<section id="artistWrapper">
<section v-if="artist" id="artistWrapper">
<ScreenHeaderSkeleton v-if="loading" />
<ScreenHeader v-if="!loading && artist" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
@ -84,7 +84,7 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
@ -104,7 +104,7 @@ type Tab = 'Songs' | 'Albums' | 'Info'
const activeTab = ref<Tab>('Songs')
const { showErrorDialog } = useDialogBox()
const { getRouteParam, go } = useRouter()
const { getRouteParam, go, onScreenActivated } = useRouter()
const artistId = ref<number>()
const artist = ref<Artist>()
@ -146,7 +146,7 @@ watch(activeTab, async tab => {
})
watch(artistId, async id => {
if (!id) return
if (!id || loading.value) return
loading.value = true
@ -165,7 +165,7 @@ watch(artistId, async id => {
const download = () => downloadService.fromArtist(artist.value!)
onMounted(() => (artistId.value = parseInt(getRouteParam('id')!)))
onScreenActivated('Artist', () => (artistId.value = parseInt(getRouteParam('id')!)))
// if the current artist has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || go('artists'))

View file

@ -76,7 +76,7 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const { onRouteChanged, triggerNotFound } = useRouter()
const { onRouteChanged, triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
const playlistId = ref<number>()
const playlist = ref<Playlist>()
@ -119,6 +119,8 @@ const editPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playli
const removeSelected = async () => await removeSongsFromPlaylist(playlist.value!, selectedSongs.value)
const fetchSongs = async (refresh = false) => {
if (loading.value) return
loading.value = true
songs.value = await songStore.fetchForPlaylist(playlist.value!, refresh)
loading.value = false
@ -132,7 +134,7 @@ watch(playlistId, async id => {
playlist.value ? await fetchSongs() : await triggerNotFound()
})
onRouteChanged(route => route.screen === 'Playlist' && (playlistId.value = parseInt(route.params!.id)))
onScreenActivated('Playlist', async () => (playlistId.value = parseInt(getRouteParam('id')!)))
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {

View file

@ -9,8 +9,12 @@ import BtnGroup from '@/components/ui/BtnGroup.vue'
import UserListScreen from './UserListScreen.vue'
new class extends UnitTestCase {
private async renderComponent () {
const fetchMock = this.mock(http, 'get').mockResolvedValue(factory<User>('user', 6))
private async renderComponent (users: User[] = []) {
if (users.length === 0) {
users = factory<User>('user', 6)
}
const fetchMock = this.mock(http, 'get').mockResolvedValue(users)
this.render(UserListScreen, {
global: {
@ -30,7 +34,17 @@ new class extends UnitTestCase {
protected test () {
it('displays a list of users', async () => {
await this.renderComponent()
expect(screen.getAllByTestId('user-card')).toHaveLength(6)
expect(screen.queryByTestId('prospects-heading')).toBeNull()
})
it('displays a list of user prospects', async () => {
const users = [...factory.states('prospect')<User>('user', 2), ...factory<User>('user', 3)]
await this.renderComponent(users)
expect(screen.getAllByTestId('user-card')).toHaveLength(5)
screen.getByTestId('prospects-heading')
})
it('triggers create user modal', async () => {
@ -41,5 +55,14 @@ new class extends UnitTestCase {
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_ADD_USER_FORM')
})
it('triggers invite user modal', async () => {
const emitMock = this.mock(eventBus, 'emit')
await this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Invite' }))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_INVITE_USER_FORM')
})
}
}

View file

@ -10,6 +10,7 @@
<icon :icon="faPlus" />
Add
</Btn>
<Btn class="btn-invite" orange @click="showInviteUserForm">Invite</Btn>
</BtnGroup>
</template>
</ScreenHeader>
@ -20,6 +21,20 @@
<UserCard :user="user" />
</li>
</ul>
<template v-if="prospects.length">
<h2 class="invited-heading" data-testid="prospects-heading">
<i />
<span>Invited</span>
<i />
</h2>
<ul class="users">
<li v-for="user in prospects" :key="user.id">
<UserCard :user="user" />
</li>
</ul>
</template>
</div>
</section>
</template>
@ -27,7 +42,7 @@
<script lang="ts" setup>
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import isMobile from 'ismobilejs'
import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { userStore } from '@/stores'
import { eventBus } from '@/utils'
@ -38,11 +53,15 @@ import UserCard from '@/components/user/UserCard.vue'
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue'))
const users = toRef(userStore.state, 'users')
const allUsers = toRef(userStore.state, 'users')
const users = computed(() => allUsers.value.filter(user => !user.is_prospect))
const prospects = computed(() => allUsers.value.filter(user => user.is_prospect))
const isPhone = isMobile.phone
const showingControls = ref(false)
const showAddUserForm = () => eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
const showInviteUserForm = () => eventBus.emit('MODAL_SHOW_INVITE_USER_FORM')
onMounted(async () => await userStore.fetch())
</script>
@ -53,4 +72,48 @@ onMounted(async () => await userStore.fetch())
grid-gap: .7rem 1rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.invited-heading {
margin: 2rem 0 1rem;
text-transform: uppercase;
letter-spacing: .1rem;
color: var(--color-text-secondary);
text-align: center;
position: relative;
display: flex;
justify-content: center;
i {
position: relative;
flex: 1;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--color-text-secondary);
opacity: .2;
}
}
span {
padding: 0.2rem .8rem;
position: relative;
&::before {
border: 1px solid var(--color-text-secondary);
opacity: .2;
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 5px;
}
}
}
</style>

View file

@ -33,3 +33,71 @@ exports[`renders 1`] = `
</header><br data-testid="song-list">
</section>
`;
exports[`renders 2`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5="">
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-55bfc268="" data-v-5691beb5-s=""><span data-testid="thumbnail" data-v-55bfc268=""></span></div>
</aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5=""> All Songs
<!--v-if-->
</h1><span class="meta text-secondary" data-v-5691beb5=""><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s="">
<div class="wrapper" data-v-d396e0d2=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div class="menu-wrapper" data-v-d396e0d2="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section><button transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
</div>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;
exports[`renders 3`] = `
<section id="songsWrapper">
<header data-v-5691beb5="" class="screen-header expanded">
<aside data-v-5691beb5="" class="thumbnail-wrapper">
<div data-v-55bfc268="" data-v-5691beb5-s="" class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"><span data-v-55bfc268="" data-testid="thumbnail"></span></div>
</aside>
<main data-v-5691beb5="">
<div data-v-5691beb5="" class="heading-wrapper">
<h1 data-v-5691beb5="" class="name"> All Songs
<!--v-if-->
</h1><span data-v-5691beb5="" class="meta text-secondary"><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div data-v-d396e0d2="" data-v-5691beb5-s="" class="song-list-controls" data-testid="song-list-controls">
<div data-v-d396e0d2="" class="wrapper"><span data-v-e884c19a="" data-v-d396e0d2="" class="btn-group" uppercased=""><button data-v-e368fe26="" data-v-d396e0d2="" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs"><br data-v-d396e0d2="" data-testid="icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if-->
<!--v-if-->
</div>
<div data-v-d396e0d2="" class="menu-wrapper">
<div data-v-42061e3e="" data-v-d396e0d2="" class="add-to" data-testid="add-to-menu" tabindex="0">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</ul>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
</div>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;

View file

@ -2,11 +2,11 @@
exports[`renders 1`] = `
<section id="settingsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5=""></aside>
<header data-v-5691beb5="" class="screen-header expanded">
<aside data-v-5691beb5="" class="thumbnail-wrapper"></aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5="">Settings</h1><span class="meta text-secondary" data-v-5691beb5=""></span>
<div data-v-5691beb5="" class="heading-wrapper">
<h1 data-v-5691beb5="" class="name">Settings</h1><span data-v-5691beb5="" class="meta text-secondary"></span>
</div>
</main>
</header>
@ -14,7 +14,7 @@ exports[`renders 1`] = `
<div class="form-row"><label for="inputSettingsPath">Media Path</label>
<p id="mediaPathHelp" class="help"> The <em>absolute</em> path to the server directory containing your media. Koel will scan this directory for songs and extract any available information.<br> Scanning may take a while, especially if you have a lot of songs, so be patient. </p><input id="inputSettingsPath" aria-describedby="mediaPathHelp" name="media_path" type="text">
</div>
<div class="form-row"><button type="submit" data-v-e368fe26="">Scan</button></div>
<div class="form-row"><button data-v-e368fe26="" type="submit">Scan</button></div>
</form>
</section>
`;

View file

@ -61,7 +61,7 @@
<script lang="ts" setup>
import { computed, ref, toRef } from 'vue'
import { arrayify, copyText, eventBus, pluralize } from '@/utils'
import { commonStore, favoriteStore, playlistStore, queueStore, songStore, userStore } from '@/stores'
import { commonStore, favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
import { downloadService, playbackService } from '@/services'
import {
useAuthorization,

View file

@ -13,7 +13,7 @@ new class extends UnitTestCase {
this.router.activateRoute({
screen,
path: '_',
path: '_'
})
return this.render(SongListControls, {

View file

@ -1,16 +1,16 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="add-to" data-testid="add-to-menu" tabindex="0" showing="true" data-v-42061e3e="">
<section class="existing-playlists" data-v-42061e3e="">
<div data-v-42061e3e="" class="add-to" data-testid="add-to-menu" tabindex="0" showing="true">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="">Add 5 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Foo</li>
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Bar</li>
<li class="playlist" data-testid="add-to-playlist" tabindex="0" data-v-42061e3e="">Baz</li>
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
<li data-v-42061e3e="" class="playlist" data-testid="add-to-playlist" tabindex="0">Foo</li>
<li data-v-42061e3e="" class="playlist" data-testid="add-to-playlist" tabindex="0">Bar</li>
<li data-v-42061e3e="" class="playlist" data-testid="add-to-playlist" tabindex="0">Baz</li>
</ul>
</section><button type="button" transparent="" data-v-e368fe26="" data-v-42061e3e="">New Playlist…</button>
</section><button data-v-e368fe26="" data-v-42061e3e="" transparent="">New Playlist…</button>
</div>
`;

View file

@ -2,440 +2,440 @@
exports[`edits a single song 1`] = `
<form data-v-d70eb300="">
<header data-v-d70eb300=""><span class="cover" style="background-image: url(http://test/album.jpg);" data-v-d70eb300=""></span>
<div class="meta" data-v-d70eb300="">
<h1 class="" data-v-d70eb300="">Rocket to Heaven</h1>
<h2 data-testid="displayed-artist-name" class="" data-v-d70eb300="">Led Zeppelin</h2>
<h2 data-testid="displayed-album-name" class="" data-v-d70eb300="">IV</h2>
<header data-v-d70eb300=""><span data-v-d70eb300="" class="cover" style="background-image: url(http://test/album.jpg);"></span>
<div data-v-d70eb300="" class="meta">
<h1 data-v-d70eb300="" class="">Rocket to Heaven</h1>
<h2 data-v-d70eb300="" data-testid="displayed-artist-name" class="">Led Zeppelin</h2>
<h2 data-v-d70eb300="" data-testid="displayed-album-name" class="">IV</h2>
</div>
</header>
<main class="tabs" data-v-d70eb300="">
<div class="clear" role="tablist" data-v-d70eb300=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-d70eb300=""> Details </button><button id="editSongTabLyrics" aria-selected="false" aria-controls="editSongPanelLyrics" data-testid="edit-song-lyrics-tab" role="tab" type="button" data-v-d70eb300=""> Lyrics </button></div>
<div class="panes" data-v-d70eb300="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-d70eb300="">
<div class="form-row" data-v-d70eb300=""><label data-v-d70eb300=""> Title <input data-testid="title-input" name="title" title="Title" type="text" data-v-d70eb300=""></label></div>
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Artist <input placeholder="" data-testid="artist-input" name="artist" type="text" data-v-d70eb300=""></label><label data-v-d70eb300=""> Album Artist <input placeholder="" data-testid="albumArtist-input" name="album_artist" type="text" data-v-d70eb300=""></label></div>
<div class="form-row" data-v-d70eb300=""><label data-v-d70eb300=""> Album <input placeholder="" data-testid="album-input" name="album" type="text" data-v-d70eb300=""></label></div>
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Track <input placeholder="" data-testid="track-input" min="1" name="track" type="number" data-v-d70eb300=""></label><label data-v-d70eb300=""> Disc <input placeholder="" data-testid="disc-input" min="1" name="disc" type="number" data-v-d70eb300=""></label></div>
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Genre <input placeholder="" data-testid="genre-input" name="genre" type="text" list="genres" data-v-d70eb300=""><datalist id="genres" data-v-d70eb300="">
<option value="Blues" data-v-d70eb300=""></option>
<option value="Classic Rock" data-v-d70eb300=""></option>
<option value="Country" data-v-d70eb300=""></option>
<option value="Dance" data-v-d70eb300=""></option>
<option value="Disco" data-v-d70eb300=""></option>
<option value="Funk" data-v-d70eb300=""></option>
<option value="Grunge" data-v-d70eb300=""></option>
<option value="Hip-Hop" data-v-d70eb300=""></option>
<option value="Jazz" data-v-d70eb300=""></option>
<option value="Metal" data-v-d70eb300=""></option>
<option value="New Age" data-v-d70eb300=""></option>
<option value="Oldies" data-v-d70eb300=""></option>
<option value="Other" data-v-d70eb300=""></option>
<option value="Pop" data-v-d70eb300=""></option>
<option value="R&amp;B" data-v-d70eb300=""></option>
<option value="Rap" data-v-d70eb300=""></option>
<option value="Reggae" data-v-d70eb300=""></option>
<option value="Rock" data-v-d70eb300=""></option>
<option value="Techno" data-v-d70eb300=""></option>
<option value="Industrial" data-v-d70eb300=""></option>
<option value="Alternative" data-v-d70eb300=""></option>
<option value="Ska" data-v-d70eb300=""></option>
<option value="Death Metal" data-v-d70eb300=""></option>
<option value="Pranks" data-v-d70eb300=""></option>
<option value="Soundtrack" data-v-d70eb300=""></option>
<option value="Euro-Techno" data-v-d70eb300=""></option>
<option value="Ambient" data-v-d70eb300=""></option>
<option value="Trip-Hop" data-v-d70eb300=""></option>
<option value="Vocal" data-v-d70eb300=""></option>
<option value="Jazz &amp; Funk" data-v-d70eb300=""></option>
<option value="Fusion" data-v-d70eb300=""></option>
<option value="Trance" data-v-d70eb300=""></option>
<option value="Classical" data-v-d70eb300=""></option>
<option value="Instrumental" data-v-d70eb300=""></option>
<option value="Acid" data-v-d70eb300=""></option>
<option value="House" data-v-d70eb300=""></option>
<option value="Game" data-v-d70eb300=""></option>
<option value="Sound Clip" data-v-d70eb300=""></option>
<option value="Gospel" data-v-d70eb300=""></option>
<option value="Noise" data-v-d70eb300=""></option>
<option value="Alternative Rock" data-v-d70eb300=""></option>
<option value="Bass" data-v-d70eb300=""></option>
<option value="Punk" data-v-d70eb300=""></option>
<option value="Space" data-v-d70eb300=""></option>
<option value="Meditative" data-v-d70eb300=""></option>
<option value="Instrumental Pop" data-v-d70eb300=""></option>
<option value="Instrumental Rock" data-v-d70eb300=""></option>
<option value="Ethnic" data-v-d70eb300=""></option>
<option value="Gothic" data-v-d70eb300=""></option>
<option value="Darkwave" data-v-d70eb300=""></option>
<option value="Techno-Industrial" data-v-d70eb300=""></option>
<option value="Electronic" data-v-d70eb300=""></option>
<option value="Pop-Folk" data-v-d70eb300=""></option>
<option value="Eurodance" data-v-d70eb300=""></option>
<option value="Dream" data-v-d70eb300=""></option>
<option value="Southern Rock" data-v-d70eb300=""></option>
<option value="Comedy" data-v-d70eb300=""></option>
<option value="Cult" data-v-d70eb300=""></option>
<option value="Gangsta" data-v-d70eb300=""></option>
<option value="Top 40" data-v-d70eb300=""></option>
<option value="Christian Rap" data-v-d70eb300=""></option>
<option value="Pop/Funk" data-v-d70eb300=""></option>
<option value="Jungle" data-v-d70eb300=""></option>
<option value="Native US" data-v-d70eb300=""></option>
<option value="Cabaret" data-v-d70eb300=""></option>
<option value="New Wave" data-v-d70eb300=""></option>
<option value="Psychedelic" data-v-d70eb300=""></option>
<option value="Rave" data-v-d70eb300=""></option>
<option value="Showtunes" data-v-d70eb300=""></option>
<option value="Trailer" data-v-d70eb300=""></option>
<option value="Lo-Fi" data-v-d70eb300=""></option>
<option value="Tribal" data-v-d70eb300=""></option>
<option value="Acid Punk" data-v-d70eb300=""></option>
<option value="Acid Jazz" data-v-d70eb300=""></option>
<option value="Polka" data-v-d70eb300=""></option>
<option value="Retro" data-v-d70eb300=""></option>
<option value="Musical" data-v-d70eb300=""></option>
<option value="Rock &amp; Roll" data-v-d70eb300=""></option>
<option value="Hard Rock" data-v-d70eb300=""></option>
<option value="Folk" data-v-d70eb300=""></option>
<option value="Folk-Rock" data-v-d70eb300=""></option>
<option value="National Folk" data-v-d70eb300=""></option>
<option value="Swing" data-v-d70eb300=""></option>
<option value="Fast Fusion" data-v-d70eb300=""></option>
<option value="Bebob" data-v-d70eb300=""></option>
<option value="Latin" data-v-d70eb300=""></option>
<option value="Revival" data-v-d70eb300=""></option>
<option value="Celtic" data-v-d70eb300=""></option>
<option value="Bluegrass" data-v-d70eb300=""></option>
<option value="Avantgarde" data-v-d70eb300=""></option>
<option value="Gothic Rock" data-v-d70eb300=""></option>
<option value="Progressive Rock" data-v-d70eb300=""></option>
<option value="Psychedelic Rock" data-v-d70eb300=""></option>
<option value="Symphonic Rock" data-v-d70eb300=""></option>
<option value="Slow Rock" data-v-d70eb300=""></option>
<option value="Big Band" data-v-d70eb300=""></option>
<option value="Chorus" data-v-d70eb300=""></option>
<option value="Easy Listening" data-v-d70eb300=""></option>
<option value="Acoustic" data-v-d70eb300=""></option>
<option value="Humour" data-v-d70eb300=""></option>
<option value="Speech" data-v-d70eb300=""></option>
<option value="Chanson" data-v-d70eb300=""></option>
<option value="Opera" data-v-d70eb300=""></option>
<option value="Chamber Music" data-v-d70eb300=""></option>
<option value="Sonata" data-v-d70eb300=""></option>
<option value="Symphony" data-v-d70eb300=""></option>
<option value="Booty Bass" data-v-d70eb300=""></option>
<option value="Primus" data-v-d70eb300=""></option>
<option value="Porn Groove" data-v-d70eb300=""></option>
<option value="Satire" data-v-d70eb300=""></option>
<option value="Slow Jam" data-v-d70eb300=""></option>
<option value="Club" data-v-d70eb300=""></option>
<option value="Tango" data-v-d70eb300=""></option>
<option value="Samba" data-v-d70eb300=""></option>
<option value="Folklore" data-v-d70eb300=""></option>
<option value="Ballad" data-v-d70eb300=""></option>
<option value="Power Ballad" data-v-d70eb300=""></option>
<option value="Rhythmic Soul" data-v-d70eb300=""></option>
<option value="Freestyle" data-v-d70eb300=""></option>
<option value="Duet" data-v-d70eb300=""></option>
<option value="Punk Rock" data-v-d70eb300=""></option>
<option value="Drum Solo" data-v-d70eb300=""></option>
<option value="A cappella" data-v-d70eb300=""></option>
<option value="Euro-House" data-v-d70eb300=""></option>
<option value="Dance Hall" data-v-d70eb300=""></option>
<option value="Goa" data-v-d70eb300=""></option>
<option value="Drum &amp; Bass" data-v-d70eb300=""></option>
<option value="Club-House" data-v-d70eb300=""></option>
<option value="Hardcore Techno" data-v-d70eb300=""></option>
<option value="Terror" data-v-d70eb300=""></option>
<option value="Indie" data-v-d70eb300=""></option>
<option value="BritPop" data-v-d70eb300=""></option>
<option value="Negerpunk" data-v-d70eb300=""></option>
<option value="Polsk Punk" data-v-d70eb300=""></option>
<option value="Beat" data-v-d70eb300=""></option>
<option value="Christian Gangsta Rap" data-v-d70eb300=""></option>
<option value="Heavy Metal" data-v-d70eb300=""></option>
<option value="Black Metal" data-v-d70eb300=""></option>
<option value="Crossover" data-v-d70eb300=""></option>
<option value="Contemporary Christian" data-v-d70eb300=""></option>
<option value="Christian Rock" data-v-d70eb300=""></option>
<option value="Merengue" data-v-d70eb300=""></option>
<option value="Salsa" data-v-d70eb300=""></option>
<option value="Thrash Metal" data-v-d70eb300=""></option>
<option value="Anime" data-v-d70eb300=""></option>
<option value="JPop" data-v-d70eb300=""></option>
<option value="SynthPop" data-v-d70eb300=""></option>
<option value="Abstract" data-v-d70eb300=""></option>
<option value="Art Rock" data-v-d70eb300=""></option>
<option value="Baroque" data-v-d70eb300=""></option>
<option value="Bhangra" data-v-d70eb300=""></option>
<option value="Big beat" data-v-d70eb300=""></option>
<option value="Breakbeat" data-v-d70eb300=""></option>
<option value="Chillout" data-v-d70eb300=""></option>
<option value="Downtempo" data-v-d70eb300=""></option>
<option value="Dub" data-v-d70eb300=""></option>
<option value="EBM" data-v-d70eb300=""></option>
<option value="Eclectic" data-v-d70eb300=""></option>
<option value="Electro" data-v-d70eb300=""></option>
<option value="Electroclash" data-v-d70eb300=""></option>
<option value="Emo" data-v-d70eb300=""></option>
<option value="Experimental" data-v-d70eb300=""></option>
<option value="Garage" data-v-d70eb300=""></option>
<option value="Global" data-v-d70eb300=""></option>
<option value="IDM" data-v-d70eb300=""></option>
<option value="Illbient" data-v-d70eb300=""></option>
<option value="Industro-Goth" data-v-d70eb300=""></option>
<option value="Jam Band" data-v-d70eb300=""></option>
<option value="Krautrock" data-v-d70eb300=""></option>
<option value="Leftfield" data-v-d70eb300=""></option>
<option value="Lounge" data-v-d70eb300=""></option>
<option value="Math Rock" data-v-d70eb300=""></option>
<option value="New Romantic" data-v-d70eb300=""></option>
<option value="Nu-Breakz" data-v-d70eb300=""></option>
<option value="Post-Punk" data-v-d70eb300=""></option>
<option value="Post-Rock" data-v-d70eb300=""></option>
<option value="Psytrance" data-v-d70eb300=""></option>
<option value="Shoegaze" data-v-d70eb300=""></option>
<option value="Space Rock" data-v-d70eb300=""></option>
<option value="Trop Rock" data-v-d70eb300=""></option>
<option value="World Music" data-v-d70eb300=""></option>
<option value="Neoclassical" data-v-d70eb300=""></option>
<option value="Audiobook" data-v-d70eb300=""></option>
<option value="Audio Theatre" data-v-d70eb300=""></option>
<option value="Neue Deutsche Welle" data-v-d70eb300=""></option>
<option value="Podcast" data-v-d70eb300=""></option>
<option value="Indie-Rock" data-v-d70eb300=""></option>
<option value="G-Funk" data-v-d70eb300=""></option>
<option value="Dubstep" data-v-d70eb300=""></option>
<option value="Garage Rock" data-v-d70eb300=""></option>
<option value="Psybient" data-v-d70eb300=""></option>
</datalist></label><label data-v-d70eb300=""> Year <input placeholder="" data-testid="year-input" name="year" type="number" data-v-d70eb300=""></label></div>
<main data-v-d70eb300="" class="tabs">
<div data-v-d70eb300="" class="clear" role="tablist"><button data-v-d70eb300="" id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button"> Details </button><button data-v-d70eb300="" id="editSongTabLyrics" aria-selected="false" aria-controls="editSongPanelLyrics" data-testid="edit-song-lyrics-tab" role="tab" type="button"> Lyrics </button></div>
<div data-v-d70eb300="" class="panes">
<div data-v-d70eb300="" id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0">
<div data-v-d70eb300="" class="form-row"><label data-v-d70eb300=""> Title <input data-v-d70eb300="" data-testid="title-input" name="title" title="Title" type="text"></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Artist <input data-v-d70eb300="" placeholder="" data-testid="artist-input" name="artist" type="text"></label><label data-v-d70eb300=""> Album Artist <input data-v-d70eb300="" placeholder="" data-testid="albumArtist-input" name="album_artist" type="text"></label></div>
<div data-v-d70eb300="" class="form-row"><label data-v-d70eb300=""> Album <input data-v-d70eb300="" placeholder="" data-testid="album-input" name="album" type="text"></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Track <input data-v-d70eb300="" placeholder="" data-testid="track-input" min="1" name="track" type="number"></label><label data-v-d70eb300=""> Disc <input data-v-d70eb300="" placeholder="" data-testid="disc-input" min="1" name="disc" type="number"></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Genre <input data-v-d70eb300="" placeholder="" data-testid="genre-input" name="genre" type="text" list="genres"><datalist data-v-d70eb300="" id="genres">
<option data-v-d70eb300="" value="Blues"></option>
<option data-v-d70eb300="" value="Classic Rock"></option>
<option data-v-d70eb300="" value="Country"></option>
<option data-v-d70eb300="" value="Dance"></option>
<option data-v-d70eb300="" value="Disco"></option>
<option data-v-d70eb300="" value="Funk"></option>
<option data-v-d70eb300="" value="Grunge"></option>
<option data-v-d70eb300="" value="Hip-Hop"></option>
<option data-v-d70eb300="" value="Jazz"></option>
<option data-v-d70eb300="" value="Metal"></option>
<option data-v-d70eb300="" value="New Age"></option>
<option data-v-d70eb300="" value="Oldies"></option>
<option data-v-d70eb300="" value="Other"></option>
<option data-v-d70eb300="" value="Pop"></option>
<option data-v-d70eb300="" value="R&amp;B"></option>
<option data-v-d70eb300="" value="Rap"></option>
<option data-v-d70eb300="" value="Reggae"></option>
<option data-v-d70eb300="" value="Rock"></option>
<option data-v-d70eb300="" value="Techno"></option>
<option data-v-d70eb300="" value="Industrial"></option>
<option data-v-d70eb300="" value="Alternative"></option>
<option data-v-d70eb300="" value="Ska"></option>
<option data-v-d70eb300="" value="Death Metal"></option>
<option data-v-d70eb300="" value="Pranks"></option>
<option data-v-d70eb300="" value="Soundtrack"></option>
<option data-v-d70eb300="" value="Euro-Techno"></option>
<option data-v-d70eb300="" value="Ambient"></option>
<option data-v-d70eb300="" value="Trip-Hop"></option>
<option data-v-d70eb300="" value="Vocal"></option>
<option data-v-d70eb300="" value="Jazz &amp; Funk"></option>
<option data-v-d70eb300="" value="Fusion"></option>
<option data-v-d70eb300="" value="Trance"></option>
<option data-v-d70eb300="" value="Classical"></option>
<option data-v-d70eb300="" value="Instrumental"></option>
<option data-v-d70eb300="" value="Acid"></option>
<option data-v-d70eb300="" value="House"></option>
<option data-v-d70eb300="" value="Game"></option>
<option data-v-d70eb300="" value="Sound Clip"></option>
<option data-v-d70eb300="" value="Gospel"></option>
<option data-v-d70eb300="" value="Noise"></option>
<option data-v-d70eb300="" value="Alternative Rock"></option>
<option data-v-d70eb300="" value="Bass"></option>
<option data-v-d70eb300="" value="Punk"></option>
<option data-v-d70eb300="" value="Space"></option>
<option data-v-d70eb300="" value="Meditative"></option>
<option data-v-d70eb300="" value="Instrumental Pop"></option>
<option data-v-d70eb300="" value="Instrumental Rock"></option>
<option data-v-d70eb300="" value="Ethnic"></option>
<option data-v-d70eb300="" value="Gothic"></option>
<option data-v-d70eb300="" value="Darkwave"></option>
<option data-v-d70eb300="" value="Techno-Industrial"></option>
<option data-v-d70eb300="" value="Electronic"></option>
<option data-v-d70eb300="" value="Pop-Folk"></option>
<option data-v-d70eb300="" value="Eurodance"></option>
<option data-v-d70eb300="" value="Dream"></option>
<option data-v-d70eb300="" value="Southern Rock"></option>
<option data-v-d70eb300="" value="Comedy"></option>
<option data-v-d70eb300="" value="Cult"></option>
<option data-v-d70eb300="" value="Gangsta"></option>
<option data-v-d70eb300="" value="Top 40"></option>
<option data-v-d70eb300="" value="Christian Rap"></option>
<option data-v-d70eb300="" value="Pop/Funk"></option>
<option data-v-d70eb300="" value="Jungle"></option>
<option data-v-d70eb300="" value="Native US"></option>
<option data-v-d70eb300="" value="Cabaret"></option>
<option data-v-d70eb300="" value="New Wave"></option>
<option data-v-d70eb300="" value="Psychedelic"></option>
<option data-v-d70eb300="" value="Rave"></option>
<option data-v-d70eb300="" value="Showtunes"></option>
<option data-v-d70eb300="" value="Trailer"></option>
<option data-v-d70eb300="" value="Lo-Fi"></option>
<option data-v-d70eb300="" value="Tribal"></option>
<option data-v-d70eb300="" value="Acid Punk"></option>
<option data-v-d70eb300="" value="Acid Jazz"></option>
<option data-v-d70eb300="" value="Polka"></option>
<option data-v-d70eb300="" value="Retro"></option>
<option data-v-d70eb300="" value="Musical"></option>
<option data-v-d70eb300="" value="Rock &amp; Roll"></option>
<option data-v-d70eb300="" value="Hard Rock"></option>
<option data-v-d70eb300="" value="Folk"></option>
<option data-v-d70eb300="" value="Folk-Rock"></option>
<option data-v-d70eb300="" value="National Folk"></option>
<option data-v-d70eb300="" value="Swing"></option>
<option data-v-d70eb300="" value="Fast Fusion"></option>
<option data-v-d70eb300="" value="Bebob"></option>
<option data-v-d70eb300="" value="Latin"></option>
<option data-v-d70eb300="" value="Revival"></option>
<option data-v-d70eb300="" value="Celtic"></option>
<option data-v-d70eb300="" value="Bluegrass"></option>
<option data-v-d70eb300="" value="Avantgarde"></option>
<option data-v-d70eb300="" value="Gothic Rock"></option>
<option data-v-d70eb300="" value="Progressive Rock"></option>
<option data-v-d70eb300="" value="Psychedelic Rock"></option>
<option data-v-d70eb300="" value="Symphonic Rock"></option>
<option data-v-d70eb300="" value="Slow Rock"></option>
<option data-v-d70eb300="" value="Big Band"></option>
<option data-v-d70eb300="" value="Chorus"></option>
<option data-v-d70eb300="" value="Easy Listening"></option>
<option data-v-d70eb300="" value="Acoustic"></option>
<option data-v-d70eb300="" value="Humour"></option>
<option data-v-d70eb300="" value="Speech"></option>
<option data-v-d70eb300="" value="Chanson"></option>
<option data-v-d70eb300="" value="Opera"></option>
<option data-v-d70eb300="" value="Chamber Music"></option>
<option data-v-d70eb300="" value="Sonata"></option>
<option data-v-d70eb300="" value="Symphony"></option>
<option data-v-d70eb300="" value="Booty Bass"></option>
<option data-v-d70eb300="" value="Primus"></option>
<option data-v-d70eb300="" value="Porn Groove"></option>
<option data-v-d70eb300="" value="Satire"></option>
<option data-v-d70eb300="" value="Slow Jam"></option>
<option data-v-d70eb300="" value="Club"></option>
<option data-v-d70eb300="" value="Tango"></option>
<option data-v-d70eb300="" value="Samba"></option>
<option data-v-d70eb300="" value="Folklore"></option>
<option data-v-d70eb300="" value="Ballad"></option>
<option data-v-d70eb300="" value="Power Ballad"></option>
<option data-v-d70eb300="" value="Rhythmic Soul"></option>
<option data-v-d70eb300="" value="Freestyle"></option>
<option data-v-d70eb300="" value="Duet"></option>
<option data-v-d70eb300="" value="Punk Rock"></option>
<option data-v-d70eb300="" value="Drum Solo"></option>
<option data-v-d70eb300="" value="A cappella"></option>
<option data-v-d70eb300="" value="Euro-House"></option>
<option data-v-d70eb300="" value="Dance Hall"></option>
<option data-v-d70eb300="" value="Goa"></option>
<option data-v-d70eb300="" value="Drum &amp; Bass"></option>
<option data-v-d70eb300="" value="Club-House"></option>
<option data-v-d70eb300="" value="Hardcore Techno"></option>
<option data-v-d70eb300="" value="Terror"></option>
<option data-v-d70eb300="" value="Indie"></option>
<option data-v-d70eb300="" value="BritPop"></option>
<option data-v-d70eb300="" value="Negerpunk"></option>
<option data-v-d70eb300="" value="Polsk Punk"></option>
<option data-v-d70eb300="" value="Beat"></option>
<option data-v-d70eb300="" value="Christian Gangsta Rap"></option>
<option data-v-d70eb300="" value="Heavy Metal"></option>
<option data-v-d70eb300="" value="Black Metal"></option>
<option data-v-d70eb300="" value="Crossover"></option>
<option data-v-d70eb300="" value="Contemporary Christian"></option>
<option data-v-d70eb300="" value="Christian Rock"></option>
<option data-v-d70eb300="" value="Merengue"></option>
<option data-v-d70eb300="" value="Salsa"></option>
<option data-v-d70eb300="" value="Thrash Metal"></option>
<option data-v-d70eb300="" value="Anime"></option>
<option data-v-d70eb300="" value="JPop"></option>
<option data-v-d70eb300="" value="SynthPop"></option>
<option data-v-d70eb300="" value="Abstract"></option>
<option data-v-d70eb300="" value="Art Rock"></option>
<option data-v-d70eb300="" value="Baroque"></option>
<option data-v-d70eb300="" value="Bhangra"></option>
<option data-v-d70eb300="" value="Big beat"></option>
<option data-v-d70eb300="" value="Breakbeat"></option>
<option data-v-d70eb300="" value="Chillout"></option>
<option data-v-d70eb300="" value="Downtempo"></option>
<option data-v-d70eb300="" value="Dub"></option>
<option data-v-d70eb300="" value="EBM"></option>
<option data-v-d70eb300="" value="Eclectic"></option>
<option data-v-d70eb300="" value="Electro"></option>
<option data-v-d70eb300="" value="Electroclash"></option>
<option data-v-d70eb300="" value="Emo"></option>
<option data-v-d70eb300="" value="Experimental"></option>
<option data-v-d70eb300="" value="Garage"></option>
<option data-v-d70eb300="" value="Global"></option>
<option data-v-d70eb300="" value="IDM"></option>
<option data-v-d70eb300="" value="Illbient"></option>
<option data-v-d70eb300="" value="Industro-Goth"></option>
<option data-v-d70eb300="" value="Jam Band"></option>
<option data-v-d70eb300="" value="Krautrock"></option>
<option data-v-d70eb300="" value="Leftfield"></option>
<option data-v-d70eb300="" value="Lounge"></option>
<option data-v-d70eb300="" value="Math Rock"></option>
<option data-v-d70eb300="" value="New Romantic"></option>
<option data-v-d70eb300="" value="Nu-Breakz"></option>
<option data-v-d70eb300="" value="Post-Punk"></option>
<option data-v-d70eb300="" value="Post-Rock"></option>
<option data-v-d70eb300="" value="Psytrance"></option>
<option data-v-d70eb300="" value="Shoegaze"></option>
<option data-v-d70eb300="" value="Space Rock"></option>
<option data-v-d70eb300="" value="Trop Rock"></option>
<option data-v-d70eb300="" value="World Music"></option>
<option data-v-d70eb300="" value="Neoclassical"></option>
<option data-v-d70eb300="" value="Audiobook"></option>
<option data-v-d70eb300="" value="Audio Theatre"></option>
<option data-v-d70eb300="" value="Neue Deutsche Welle"></option>
<option data-v-d70eb300="" value="Podcast"></option>
<option data-v-d70eb300="" value="Indie-Rock"></option>
<option data-v-d70eb300="" value="G-Funk"></option>
<option data-v-d70eb300="" value="Dubstep"></option>
<option data-v-d70eb300="" value="Garage Rock"></option>
<option data-v-d70eb300="" value="Psybient"></option>
</datalist></label><label data-v-d70eb300=""> Year <input data-v-d70eb300="" placeholder="" data-testid="year-input" name="year" type="number"></label></div>
</div>
<div id="editSongPanelLyrics" aria-labelledby="editSongTabLyrics" role="tabpanel" tabindex="0" data-v-d70eb300="" style="display: none;">
<div class="form-row" data-v-d70eb300=""><textarea data-testid="lyrics-input" name="lyrics" title="Lyrics" data-v-d70eb300=""></textarea></div>
<div data-v-d70eb300="" id="editSongPanelLyrics" aria-labelledby="editSongTabLyrics" role="tabpanel" tabindex="0" style="display: none;">
<div data-v-d70eb300="" class="form-row"><textarea data-v-d70eb300="" data-testid="lyrics-input" name="lyrics" title="Lyrics"></textarea></div>
</div>
</div>
</main>
<footer data-v-d70eb300=""><button type="submit" data-v-e368fe26="" data-v-d70eb300="">Update</button><button type="button" class="btn-cancel" white="" data-v-e368fe26="" data-v-d70eb300="">Cancel</button></footer>
<footer data-v-d70eb300=""><button data-v-e368fe26="" data-v-d70eb300="" type="submit">Update</button><button data-v-e368fe26="" data-v-d70eb300="" class="btn-cancel" white="">Cancel</button></footer>
</form>
`;
exports[`edits multiple songs 1`] = `
<form data-v-d70eb300="">
<header data-v-d70eb300=""><span class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-d70eb300=""></span>
<div class="meta" data-v-d70eb300="">
<h1 class="mixed" data-v-d70eb300="">3 songs selected</h1>
<h2 data-testid="displayed-artist-name" class="mixed" data-v-d70eb300="">Mixed Artists</h2>
<h2 data-testid="displayed-album-name" class="mixed" data-v-d70eb300="">Mixed Albums</h2>
<header data-v-d70eb300=""><span data-v-d70eb300="" class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);"></span>
<div data-v-d70eb300="" class="meta">
<h1 data-v-d70eb300="" class="mixed">3 songs selected</h1>
<h2 data-v-d70eb300="" data-testid="displayed-artist-name" class="mixed">Mixed Artists</h2>
<h2 data-v-d70eb300="" data-testid="displayed-album-name" class="mixed">Mixed Albums</h2>
</div>
</header>
<main class="tabs" data-v-d70eb300="">
<div class="clear" role="tablist" data-v-d70eb300=""><button id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button" data-v-d70eb300=""> Details </button>
<main data-v-d70eb300="" class="tabs">
<div data-v-d70eb300="" class="clear" role="tablist"><button data-v-d70eb300="" id="editSongTabDetails" aria-selected="true" aria-controls="editSongPanelDetails" role="tab" type="button"> Details </button>
<!--v-if-->
</div>
<div class="panes" data-v-d70eb300="">
<div id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0" data-v-d70eb300="">
<div data-v-d70eb300="" class="panes">
<div data-v-d70eb300="" id="editSongPanelDetails" aria-labelledby="editSongTabDetails" role="tabpanel" tabindex="0">
<!--v-if-->
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Artist <input placeholder="Leave unchanged" data-testid="artist-input" name="artist" type="text" data-v-d70eb300=""></label><label data-v-d70eb300=""> Album Artist <input placeholder="Leave unchanged" data-testid="albumArtist-input" name="album_artist" type="text" data-v-d70eb300=""></label></div>
<div class="form-row" data-v-d70eb300=""><label data-v-d70eb300=""> Album <input placeholder="Leave unchanged" data-testid="album-input" name="album" type="text" data-v-d70eb300=""></label></div>
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Track <input placeholder="Leave unchanged" data-testid="track-input" min="1" name="track" type="number" data-v-d70eb300=""></label><label data-v-d70eb300=""> Disc <input placeholder="Leave unchanged" data-testid="disc-input" min="1" name="disc" type="number" data-v-d70eb300=""></label></div>
<div class="form-row cols" data-v-d70eb300=""><label data-v-d70eb300=""> Genre <input placeholder="Leave unchanged" data-testid="genre-input" name="genre" type="text" list="genres" data-v-d70eb300=""><datalist id="genres" data-v-d70eb300="">
<option value="Blues" data-v-d70eb300=""></option>
<option value="Classic Rock" data-v-d70eb300=""></option>
<option value="Country" data-v-d70eb300=""></option>
<option value="Dance" data-v-d70eb300=""></option>
<option value="Disco" data-v-d70eb300=""></option>
<option value="Funk" data-v-d70eb300=""></option>
<option value="Grunge" data-v-d70eb300=""></option>
<option value="Hip-Hop" data-v-d70eb300=""></option>
<option value="Jazz" data-v-d70eb300=""></option>
<option value="Metal" data-v-d70eb300=""></option>
<option value="New Age" data-v-d70eb300=""></option>
<option value="Oldies" data-v-d70eb300=""></option>
<option value="Other" data-v-d70eb300=""></option>
<option value="Pop" data-v-d70eb300=""></option>
<option value="R&amp;B" data-v-d70eb300=""></option>
<option value="Rap" data-v-d70eb300=""></option>
<option value="Reggae" data-v-d70eb300=""></option>
<option value="Rock" data-v-d70eb300=""></option>
<option value="Techno" data-v-d70eb300=""></option>
<option value="Industrial" data-v-d70eb300=""></option>
<option value="Alternative" data-v-d70eb300=""></option>
<option value="Ska" data-v-d70eb300=""></option>
<option value="Death Metal" data-v-d70eb300=""></option>
<option value="Pranks" data-v-d70eb300=""></option>
<option value="Soundtrack" data-v-d70eb300=""></option>
<option value="Euro-Techno" data-v-d70eb300=""></option>
<option value="Ambient" data-v-d70eb300=""></option>
<option value="Trip-Hop" data-v-d70eb300=""></option>
<option value="Vocal" data-v-d70eb300=""></option>
<option value="Jazz &amp; Funk" data-v-d70eb300=""></option>
<option value="Fusion" data-v-d70eb300=""></option>
<option value="Trance" data-v-d70eb300=""></option>
<option value="Classical" data-v-d70eb300=""></option>
<option value="Instrumental" data-v-d70eb300=""></option>
<option value="Acid" data-v-d70eb300=""></option>
<option value="House" data-v-d70eb300=""></option>
<option value="Game" data-v-d70eb300=""></option>
<option value="Sound Clip" data-v-d70eb300=""></option>
<option value="Gospel" data-v-d70eb300=""></option>
<option value="Noise" data-v-d70eb300=""></option>
<option value="Alternative Rock" data-v-d70eb300=""></option>
<option value="Bass" data-v-d70eb300=""></option>
<option value="Punk" data-v-d70eb300=""></option>
<option value="Space" data-v-d70eb300=""></option>
<option value="Meditative" data-v-d70eb300=""></option>
<option value="Instrumental Pop" data-v-d70eb300=""></option>
<option value="Instrumental Rock" data-v-d70eb300=""></option>
<option value="Ethnic" data-v-d70eb300=""></option>
<option value="Gothic" data-v-d70eb300=""></option>
<option value="Darkwave" data-v-d70eb300=""></option>
<option value="Techno-Industrial" data-v-d70eb300=""></option>
<option value="Electronic" data-v-d70eb300=""></option>
<option value="Pop-Folk" data-v-d70eb300=""></option>
<option value="Eurodance" data-v-d70eb300=""></option>
<option value="Dream" data-v-d70eb300=""></option>
<option value="Southern Rock" data-v-d70eb300=""></option>
<option value="Comedy" data-v-d70eb300=""></option>
<option value="Cult" data-v-d70eb300=""></option>
<option value="Gangsta" data-v-d70eb300=""></option>
<option value="Top 40" data-v-d70eb300=""></option>
<option value="Christian Rap" data-v-d70eb300=""></option>
<option value="Pop/Funk" data-v-d70eb300=""></option>
<option value="Jungle" data-v-d70eb300=""></option>
<option value="Native US" data-v-d70eb300=""></option>
<option value="Cabaret" data-v-d70eb300=""></option>
<option value="New Wave" data-v-d70eb300=""></option>
<option value="Psychedelic" data-v-d70eb300=""></option>
<option value="Rave" data-v-d70eb300=""></option>
<option value="Showtunes" data-v-d70eb300=""></option>
<option value="Trailer" data-v-d70eb300=""></option>
<option value="Lo-Fi" data-v-d70eb300=""></option>
<option value="Tribal" data-v-d70eb300=""></option>
<option value="Acid Punk" data-v-d70eb300=""></option>
<option value="Acid Jazz" data-v-d70eb300=""></option>
<option value="Polka" data-v-d70eb300=""></option>
<option value="Retro" data-v-d70eb300=""></option>
<option value="Musical" data-v-d70eb300=""></option>
<option value="Rock &amp; Roll" data-v-d70eb300=""></option>
<option value="Hard Rock" data-v-d70eb300=""></option>
<option value="Folk" data-v-d70eb300=""></option>
<option value="Folk-Rock" data-v-d70eb300=""></option>
<option value="National Folk" data-v-d70eb300=""></option>
<option value="Swing" data-v-d70eb300=""></option>
<option value="Fast Fusion" data-v-d70eb300=""></option>
<option value="Bebob" data-v-d70eb300=""></option>
<option value="Latin" data-v-d70eb300=""></option>
<option value="Revival" data-v-d70eb300=""></option>
<option value="Celtic" data-v-d70eb300=""></option>
<option value="Bluegrass" data-v-d70eb300=""></option>
<option value="Avantgarde" data-v-d70eb300=""></option>
<option value="Gothic Rock" data-v-d70eb300=""></option>
<option value="Progressive Rock" data-v-d70eb300=""></option>
<option value="Psychedelic Rock" data-v-d70eb300=""></option>
<option value="Symphonic Rock" data-v-d70eb300=""></option>
<option value="Slow Rock" data-v-d70eb300=""></option>
<option value="Big Band" data-v-d70eb300=""></option>
<option value="Chorus" data-v-d70eb300=""></option>
<option value="Easy Listening" data-v-d70eb300=""></option>
<option value="Acoustic" data-v-d70eb300=""></option>
<option value="Humour" data-v-d70eb300=""></option>
<option value="Speech" data-v-d70eb300=""></option>
<option value="Chanson" data-v-d70eb300=""></option>
<option value="Opera" data-v-d70eb300=""></option>
<option value="Chamber Music" data-v-d70eb300=""></option>
<option value="Sonata" data-v-d70eb300=""></option>
<option value="Symphony" data-v-d70eb300=""></option>
<option value="Booty Bass" data-v-d70eb300=""></option>
<option value="Primus" data-v-d70eb300=""></option>
<option value="Porn Groove" data-v-d70eb300=""></option>
<option value="Satire" data-v-d70eb300=""></option>
<option value="Slow Jam" data-v-d70eb300=""></option>
<option value="Club" data-v-d70eb300=""></option>
<option value="Tango" data-v-d70eb300=""></option>
<option value="Samba" data-v-d70eb300=""></option>
<option value="Folklore" data-v-d70eb300=""></option>
<option value="Ballad" data-v-d70eb300=""></option>
<option value="Power Ballad" data-v-d70eb300=""></option>
<option value="Rhythmic Soul" data-v-d70eb300=""></option>
<option value="Freestyle" data-v-d70eb300=""></option>
<option value="Duet" data-v-d70eb300=""></option>
<option value="Punk Rock" data-v-d70eb300=""></option>
<option value="Drum Solo" data-v-d70eb300=""></option>
<option value="A cappella" data-v-d70eb300=""></option>
<option value="Euro-House" data-v-d70eb300=""></option>
<option value="Dance Hall" data-v-d70eb300=""></option>
<option value="Goa" data-v-d70eb300=""></option>
<option value="Drum &amp; Bass" data-v-d70eb300=""></option>
<option value="Club-House" data-v-d70eb300=""></option>
<option value="Hardcore Techno" data-v-d70eb300=""></option>
<option value="Terror" data-v-d70eb300=""></option>
<option value="Indie" data-v-d70eb300=""></option>
<option value="BritPop" data-v-d70eb300=""></option>
<option value="Negerpunk" data-v-d70eb300=""></option>
<option value="Polsk Punk" data-v-d70eb300=""></option>
<option value="Beat" data-v-d70eb300=""></option>
<option value="Christian Gangsta Rap" data-v-d70eb300=""></option>
<option value="Heavy Metal" data-v-d70eb300=""></option>
<option value="Black Metal" data-v-d70eb300=""></option>
<option value="Crossover" data-v-d70eb300=""></option>
<option value="Contemporary Christian" data-v-d70eb300=""></option>
<option value="Christian Rock" data-v-d70eb300=""></option>
<option value="Merengue" data-v-d70eb300=""></option>
<option value="Salsa" data-v-d70eb300=""></option>
<option value="Thrash Metal" data-v-d70eb300=""></option>
<option value="Anime" data-v-d70eb300=""></option>
<option value="JPop" data-v-d70eb300=""></option>
<option value="SynthPop" data-v-d70eb300=""></option>
<option value="Abstract" data-v-d70eb300=""></option>
<option value="Art Rock" data-v-d70eb300=""></option>
<option value="Baroque" data-v-d70eb300=""></option>
<option value="Bhangra" data-v-d70eb300=""></option>
<option value="Big beat" data-v-d70eb300=""></option>
<option value="Breakbeat" data-v-d70eb300=""></option>
<option value="Chillout" data-v-d70eb300=""></option>
<option value="Downtempo" data-v-d70eb300=""></option>
<option value="Dub" data-v-d70eb300=""></option>
<option value="EBM" data-v-d70eb300=""></option>
<option value="Eclectic" data-v-d70eb300=""></option>
<option value="Electro" data-v-d70eb300=""></option>
<option value="Electroclash" data-v-d70eb300=""></option>
<option value="Emo" data-v-d70eb300=""></option>
<option value="Experimental" data-v-d70eb300=""></option>
<option value="Garage" data-v-d70eb300=""></option>
<option value="Global" data-v-d70eb300=""></option>
<option value="IDM" data-v-d70eb300=""></option>
<option value="Illbient" data-v-d70eb300=""></option>
<option value="Industro-Goth" data-v-d70eb300=""></option>
<option value="Jam Band" data-v-d70eb300=""></option>
<option value="Krautrock" data-v-d70eb300=""></option>
<option value="Leftfield" data-v-d70eb300=""></option>
<option value="Lounge" data-v-d70eb300=""></option>
<option value="Math Rock" data-v-d70eb300=""></option>
<option value="New Romantic" data-v-d70eb300=""></option>
<option value="Nu-Breakz" data-v-d70eb300=""></option>
<option value="Post-Punk" data-v-d70eb300=""></option>
<option value="Post-Rock" data-v-d70eb300=""></option>
<option value="Psytrance" data-v-d70eb300=""></option>
<option value="Shoegaze" data-v-d70eb300=""></option>
<option value="Space Rock" data-v-d70eb300=""></option>
<option value="Trop Rock" data-v-d70eb300=""></option>
<option value="World Music" data-v-d70eb300=""></option>
<option value="Neoclassical" data-v-d70eb300=""></option>
<option value="Audiobook" data-v-d70eb300=""></option>
<option value="Audio Theatre" data-v-d70eb300=""></option>
<option value="Neue Deutsche Welle" data-v-d70eb300=""></option>
<option value="Podcast" data-v-d70eb300=""></option>
<option value="Indie-Rock" data-v-d70eb300=""></option>
<option value="G-Funk" data-v-d70eb300=""></option>
<option value="Dubstep" data-v-d70eb300=""></option>
<option value="Garage Rock" data-v-d70eb300=""></option>
<option value="Psybient" data-v-d70eb300=""></option>
</datalist></label><label data-v-d70eb300=""> Year <input placeholder="Leave unchanged" data-testid="year-input" name="year" type="number" data-v-d70eb300=""></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Artist <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="artist-input" name="artist" type="text"></label><label data-v-d70eb300=""> Album Artist <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="albumArtist-input" name="album_artist" type="text"></label></div>
<div data-v-d70eb300="" class="form-row"><label data-v-d70eb300=""> Album <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="album-input" name="album" type="text"></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Track <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="track-input" min="1" name="track" type="number"></label><label data-v-d70eb300=""> Disc <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="disc-input" min="1" name="disc" type="number"></label></div>
<div data-v-d70eb300="" class="form-row cols"><label data-v-d70eb300=""> Genre <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="genre-input" name="genre" type="text" list="genres"><datalist data-v-d70eb300="" id="genres">
<option data-v-d70eb300="" value="Blues"></option>
<option data-v-d70eb300="" value="Classic Rock"></option>
<option data-v-d70eb300="" value="Country"></option>
<option data-v-d70eb300="" value="Dance"></option>
<option data-v-d70eb300="" value="Disco"></option>
<option data-v-d70eb300="" value="Funk"></option>
<option data-v-d70eb300="" value="Grunge"></option>
<option data-v-d70eb300="" value="Hip-Hop"></option>
<option data-v-d70eb300="" value="Jazz"></option>
<option data-v-d70eb300="" value="Metal"></option>
<option data-v-d70eb300="" value="New Age"></option>
<option data-v-d70eb300="" value="Oldies"></option>
<option data-v-d70eb300="" value="Other"></option>
<option data-v-d70eb300="" value="Pop"></option>
<option data-v-d70eb300="" value="R&amp;B"></option>
<option data-v-d70eb300="" value="Rap"></option>
<option data-v-d70eb300="" value="Reggae"></option>
<option data-v-d70eb300="" value="Rock"></option>
<option data-v-d70eb300="" value="Techno"></option>
<option data-v-d70eb300="" value="Industrial"></option>
<option data-v-d70eb300="" value="Alternative"></option>
<option data-v-d70eb300="" value="Ska"></option>
<option data-v-d70eb300="" value="Death Metal"></option>
<option data-v-d70eb300="" value="Pranks"></option>
<option data-v-d70eb300="" value="Soundtrack"></option>
<option data-v-d70eb300="" value="Euro-Techno"></option>
<option data-v-d70eb300="" value="Ambient"></option>
<option data-v-d70eb300="" value="Trip-Hop"></option>
<option data-v-d70eb300="" value="Vocal"></option>
<option data-v-d70eb300="" value="Jazz &amp; Funk"></option>
<option data-v-d70eb300="" value="Fusion"></option>
<option data-v-d70eb300="" value="Trance"></option>
<option data-v-d70eb300="" value="Classical"></option>
<option data-v-d70eb300="" value="Instrumental"></option>
<option data-v-d70eb300="" value="Acid"></option>
<option data-v-d70eb300="" value="House"></option>
<option data-v-d70eb300="" value="Game"></option>
<option data-v-d70eb300="" value="Sound Clip"></option>
<option data-v-d70eb300="" value="Gospel"></option>
<option data-v-d70eb300="" value="Noise"></option>
<option data-v-d70eb300="" value="Alternative Rock"></option>
<option data-v-d70eb300="" value="Bass"></option>
<option data-v-d70eb300="" value="Punk"></option>
<option data-v-d70eb300="" value="Space"></option>
<option data-v-d70eb300="" value="Meditative"></option>
<option data-v-d70eb300="" value="Instrumental Pop"></option>
<option data-v-d70eb300="" value="Instrumental Rock"></option>
<option data-v-d70eb300="" value="Ethnic"></option>
<option data-v-d70eb300="" value="Gothic"></option>
<option data-v-d70eb300="" value="Darkwave"></option>
<option data-v-d70eb300="" value="Techno-Industrial"></option>
<option data-v-d70eb300="" value="Electronic"></option>
<option data-v-d70eb300="" value="Pop-Folk"></option>
<option data-v-d70eb300="" value="Eurodance"></option>
<option data-v-d70eb300="" value="Dream"></option>
<option data-v-d70eb300="" value="Southern Rock"></option>
<option data-v-d70eb300="" value="Comedy"></option>
<option data-v-d70eb300="" value="Cult"></option>
<option data-v-d70eb300="" value="Gangsta"></option>
<option data-v-d70eb300="" value="Top 40"></option>
<option data-v-d70eb300="" value="Christian Rap"></option>
<option data-v-d70eb300="" value="Pop/Funk"></option>
<option data-v-d70eb300="" value="Jungle"></option>
<option data-v-d70eb300="" value="Native US"></option>
<option data-v-d70eb300="" value="Cabaret"></option>
<option data-v-d70eb300="" value="New Wave"></option>
<option data-v-d70eb300="" value="Psychedelic"></option>
<option data-v-d70eb300="" value="Rave"></option>
<option data-v-d70eb300="" value="Showtunes"></option>
<option data-v-d70eb300="" value="Trailer"></option>
<option data-v-d70eb300="" value="Lo-Fi"></option>
<option data-v-d70eb300="" value="Tribal"></option>
<option data-v-d70eb300="" value="Acid Punk"></option>
<option data-v-d70eb300="" value="Acid Jazz"></option>
<option data-v-d70eb300="" value="Polka"></option>
<option data-v-d70eb300="" value="Retro"></option>
<option data-v-d70eb300="" value="Musical"></option>
<option data-v-d70eb300="" value="Rock &amp; Roll"></option>
<option data-v-d70eb300="" value="Hard Rock"></option>
<option data-v-d70eb300="" value="Folk"></option>
<option data-v-d70eb300="" value="Folk-Rock"></option>
<option data-v-d70eb300="" value="National Folk"></option>
<option data-v-d70eb300="" value="Swing"></option>
<option data-v-d70eb300="" value="Fast Fusion"></option>
<option data-v-d70eb300="" value="Bebob"></option>
<option data-v-d70eb300="" value="Latin"></option>
<option data-v-d70eb300="" value="Revival"></option>
<option data-v-d70eb300="" value="Celtic"></option>
<option data-v-d70eb300="" value="Bluegrass"></option>
<option data-v-d70eb300="" value="Avantgarde"></option>
<option data-v-d70eb300="" value="Gothic Rock"></option>
<option data-v-d70eb300="" value="Progressive Rock"></option>
<option data-v-d70eb300="" value="Psychedelic Rock"></option>
<option data-v-d70eb300="" value="Symphonic Rock"></option>
<option data-v-d70eb300="" value="Slow Rock"></option>
<option data-v-d70eb300="" value="Big Band"></option>
<option data-v-d70eb300="" value="Chorus"></option>
<option data-v-d70eb300="" value="Easy Listening"></option>
<option data-v-d70eb300="" value="Acoustic"></option>
<option data-v-d70eb300="" value="Humour"></option>
<option data-v-d70eb300="" value="Speech"></option>
<option data-v-d70eb300="" value="Chanson"></option>
<option data-v-d70eb300="" value="Opera"></option>
<option data-v-d70eb300="" value="Chamber Music"></option>
<option data-v-d70eb300="" value="Sonata"></option>
<option data-v-d70eb300="" value="Symphony"></option>
<option data-v-d70eb300="" value="Booty Bass"></option>
<option data-v-d70eb300="" value="Primus"></option>
<option data-v-d70eb300="" value="Porn Groove"></option>
<option data-v-d70eb300="" value="Satire"></option>
<option data-v-d70eb300="" value="Slow Jam"></option>
<option data-v-d70eb300="" value="Club"></option>
<option data-v-d70eb300="" value="Tango"></option>
<option data-v-d70eb300="" value="Samba"></option>
<option data-v-d70eb300="" value="Folklore"></option>
<option data-v-d70eb300="" value="Ballad"></option>
<option data-v-d70eb300="" value="Power Ballad"></option>
<option data-v-d70eb300="" value="Rhythmic Soul"></option>
<option data-v-d70eb300="" value="Freestyle"></option>
<option data-v-d70eb300="" value="Duet"></option>
<option data-v-d70eb300="" value="Punk Rock"></option>
<option data-v-d70eb300="" value="Drum Solo"></option>
<option data-v-d70eb300="" value="A cappella"></option>
<option data-v-d70eb300="" value="Euro-House"></option>
<option data-v-d70eb300="" value="Dance Hall"></option>
<option data-v-d70eb300="" value="Goa"></option>
<option data-v-d70eb300="" value="Drum &amp; Bass"></option>
<option data-v-d70eb300="" value="Club-House"></option>
<option data-v-d70eb300="" value="Hardcore Techno"></option>
<option data-v-d70eb300="" value="Terror"></option>
<option data-v-d70eb300="" value="Indie"></option>
<option data-v-d70eb300="" value="BritPop"></option>
<option data-v-d70eb300="" value="Negerpunk"></option>
<option data-v-d70eb300="" value="Polsk Punk"></option>
<option data-v-d70eb300="" value="Beat"></option>
<option data-v-d70eb300="" value="Christian Gangsta Rap"></option>
<option data-v-d70eb300="" value="Heavy Metal"></option>
<option data-v-d70eb300="" value="Black Metal"></option>
<option data-v-d70eb300="" value="Crossover"></option>
<option data-v-d70eb300="" value="Contemporary Christian"></option>
<option data-v-d70eb300="" value="Christian Rock"></option>
<option data-v-d70eb300="" value="Merengue"></option>
<option data-v-d70eb300="" value="Salsa"></option>
<option data-v-d70eb300="" value="Thrash Metal"></option>
<option data-v-d70eb300="" value="Anime"></option>
<option data-v-d70eb300="" value="JPop"></option>
<option data-v-d70eb300="" value="SynthPop"></option>
<option data-v-d70eb300="" value="Abstract"></option>
<option data-v-d70eb300="" value="Art Rock"></option>
<option data-v-d70eb300="" value="Baroque"></option>
<option data-v-d70eb300="" value="Bhangra"></option>
<option data-v-d70eb300="" value="Big beat"></option>
<option data-v-d70eb300="" value="Breakbeat"></option>
<option data-v-d70eb300="" value="Chillout"></option>
<option data-v-d70eb300="" value="Downtempo"></option>
<option data-v-d70eb300="" value="Dub"></option>
<option data-v-d70eb300="" value="EBM"></option>
<option data-v-d70eb300="" value="Eclectic"></option>
<option data-v-d70eb300="" value="Electro"></option>
<option data-v-d70eb300="" value="Electroclash"></option>
<option data-v-d70eb300="" value="Emo"></option>
<option data-v-d70eb300="" value="Experimental"></option>
<option data-v-d70eb300="" value="Garage"></option>
<option data-v-d70eb300="" value="Global"></option>
<option data-v-d70eb300="" value="IDM"></option>
<option data-v-d70eb300="" value="Illbient"></option>
<option data-v-d70eb300="" value="Industro-Goth"></option>
<option data-v-d70eb300="" value="Jam Band"></option>
<option data-v-d70eb300="" value="Krautrock"></option>
<option data-v-d70eb300="" value="Leftfield"></option>
<option data-v-d70eb300="" value="Lounge"></option>
<option data-v-d70eb300="" value="Math Rock"></option>
<option data-v-d70eb300="" value="New Romantic"></option>
<option data-v-d70eb300="" value="Nu-Breakz"></option>
<option data-v-d70eb300="" value="Post-Punk"></option>
<option data-v-d70eb300="" value="Post-Rock"></option>
<option data-v-d70eb300="" value="Psytrance"></option>
<option data-v-d70eb300="" value="Shoegaze"></option>
<option data-v-d70eb300="" value="Space Rock"></option>
<option data-v-d70eb300="" value="Trop Rock"></option>
<option data-v-d70eb300="" value="World Music"></option>
<option data-v-d70eb300="" value="Neoclassical"></option>
<option data-v-d70eb300="" value="Audiobook"></option>
<option data-v-d70eb300="" value="Audio Theatre"></option>
<option data-v-d70eb300="" value="Neue Deutsche Welle"></option>
<option data-v-d70eb300="" value="Podcast"></option>
<option data-v-d70eb300="" value="Indie-Rock"></option>
<option data-v-d70eb300="" value="G-Funk"></option>
<option data-v-d70eb300="" value="Dubstep"></option>
<option data-v-d70eb300="" value="Garage Rock"></option>
<option data-v-d70eb300="" value="Psybient"></option>
</datalist></label><label data-v-d70eb300=""> Year <input data-v-d70eb300="" placeholder="Leave unchanged" data-testid="year-input" name="year" type="number"></label></div>
</div>
<!--v-if-->
</div>
</main>
<footer data-v-d70eb300=""><button type="submit" data-v-e368fe26="" data-v-d70eb300="">Update</button><button type="button" class="btn-cancel" white="" data-v-e368fe26="" data-v-d70eb300="">Cancel</button></footer>
<footer data-v-d70eb300=""><button data-v-e368fe26="" data-v-d70eb300="" type="submit">Update</button><button data-v-e368fe26="" data-v-d70eb300="" class="btn-cancel" white="">Cancel</button></footer>
</form>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover" data-v-a2b2e00f=""><img alt="Test Album" src="https://example.com/cover.jpg" loading="lazy" data-v-a2b2e00f=""><a title="Pause" class="control" role="button" data-v-a2b2e00f=""><br data-testid="icon" icon="[object Object]" class="text-highlight" data-v-a2b2e00f=""></a></div></span><span class="title-artist"><span class="title text-primary">Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="icon" icon="[object Object]"></button></span></div>`;
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary">Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="icon" icon="[object Object]"></button></span></div>`;

View file

@ -1,5 +1,5 @@
<template>
<button ref="button" type="button">
<button ref="button">
<slot>Click me</slot>
</button>
</template>
@ -26,7 +26,7 @@ button {
justify-content: center;
gap: .3rem;
&:hover {
&:not([disabled]):hover {
box-shadow: inset 0 0 0 10rem rgba(0, 0, 0, .05);
}

View file

@ -32,6 +32,6 @@ svg {
color: var(--color-highlight);
position: absolute;
top: 1px;
left: 2px;
left: 1px;
}
</style>

View file

@ -57,7 +57,7 @@ new class extends UnitTestCase {
it.each<[
ScreenName,
typeof favoriteStore | typeof recentlyPlayedStore,
typeof favoriteStore | typeof recentlyPlayedStore,
MethodOf<typeof favoriteStore | typeof recentlyPlayedStore>
]>([
['Favorites', favoriteStore, 'fetch'],

View file

@ -0,0 +1,48 @@
<template>
<div>
<input v-model="value" :type="type" v-bind="$attrs">
<button type="button" @click.prevent="toggleReveal">
<icon v-if="type === 'password'" :icon="faEye" />
<icon v-else :icon="faEyeSlash" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'
// we don't want the wrapping div to inherit the fallthrough attrs
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ modelValue?: string }>(), { modelValue: '' })
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
const type = ref<'password' | 'text'>('password')
const value = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const toggleReveal = () => (type.value = type.value === 'password' ? 'text' : 'password')
</script>
<style scoped lang="scss">
div {
position: relative;
}
input {
width: 100%;
}
button {
position: absolute;
padding: 8px 6px;
right: 0;
top: 0;
color: var(--color-bg-primary);
}
</style>

View file

@ -2,4 +2,8 @@
exports[`displays nothing if fetching fails 1`] = `<div style="background-image: none;" data-testid="album-art-overlay" data-v-e7775cad=""></div>`;
exports[`displays nothing if fetching fails 2`] = `<div data-v-e7775cad="" style="background-image: none;" data-testid="album-art-overlay"></div>`;
exports[`fetches and displays the album thumbnail 1`] = `<div style="background-image: url(http://test/thumb.jpg);" data-testid="album-art-overlay" data-v-e7775cad=""></div>`;
exports[`fetches and displays the album thumbnail 2`] = `<div data-v-e7775cad="" style="background-image: url(http://test/thumb.jpg);" data-testid="album-art-overlay"></div>`;

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1
exports[`renders for album 1`] = `<span class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail" data-v-e37470a2=""><img alt="IV" src="https://test/album.jpg" loading="lazy" data-v-e37470a2=""><a class="control control-play" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs in the album IV</span><span class="icon" data-v-e37470a2=""></span></a></span>`;
exports[`renders for album 1`] = `<span data-v-e37470a2="" class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-e37470a2="" alt="IV" src="https://test/album.jpg" loading="lazy"><a data-v-e37470a2="" class="control control-play" role="button"><span data-v-e37470a2="" class="hidden">Play all songs in the album IV</span><span data-v-e37470a2="" class="icon"></span></a></span>`;
exports[`renders for artist 1`] = `<span class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail" data-v-e37470a2=""><img alt="Led Zeppelin" src="https://test/blimp.jpg" loading="lazy" data-v-e37470a2=""><a class="control control-play" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs by Led Zeppelin</span><span class="icon" data-v-e37470a2=""></span></a></span>`;
exports[`renders for artist 1`] = `<span data-v-e37470a2="" class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-e37470a2="" alt="Led Zeppelin" src="https://test/blimp.jpg" loading="lazy"><a data-v-e37470a2="" class="control control-play" role="button"><span data-v-e37470a2="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-e37470a2="" class="icon"></span></a></span>`;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<a href="https://music.apple.com/buy-nao" target="_blank" title="Preview and buy this song on Apple Music" data-v-429d7b12=""><svg height="10" role="presentation" viewBox="0 0 83 20" width="41" xmlns="http://www.w3.org/2000/svg" data-v-429d7b12="">
<path d="M34.752 19.746V6.243h-.088l-5.433 13.503h-2.074L21.711 6.243h-.087v13.503h-2.548V1.399h3.235l5.833 14.621h.1L34.064 1.4h3.248v18.347h-2.56zm16.649 0h-2.586v-2.263h-.062c-.725 1.602-2.061 2.504-4.072 2.504-2.86 0-4.61-1.894-4.61-4.958V6.37h2.698v8.125c0 2.034.95 3.127 2.81 3.127 1.95 0 3.124-1.373 3.124-3.458V6.37H51.4v13.376zm7.394-13.618c3.06 0 5.046 1.73 5.134 4.196h-2.536c-.15-1.296-1.087-2.11-2.598-2.11-1.462 0-2.436.724-2.436 1.793 0 .839.6 1.41 2.023 1.741l2.136.496c2.686.636 3.71 1.704 3.71 3.636 0 2.442-2.236 4.12-5.333 4.12-3.285 0-5.26-1.64-5.509-4.183h2.673c.25 1.398 1.187 2.085 2.836 2.085 1.623 0 2.623-.687 2.623-1.78 0-.865-.487-1.373-1.924-1.704l-2.136-.508c-2.498-.585-3.735-1.806-3.735-3.75 0-2.391 2.049-4.032 5.072-4.032zM66.1 2.836c0-.878.7-1.577 1.561-1.577.862 0 1.55.7 1.55 1.577 0 .864-.688 1.576-1.55 1.576a1.573 1.573 0 0 1-1.56-1.576zm.212 3.534h2.698v13.376h-2.698V6.37zm14.089 4.603c-.275-1.424-1.324-2.556-3.085-2.556-2.086 0-3.46 1.767-3.46 4.64 0 2.938 1.386 4.642 3.485 4.642 1.66 0 2.748-.928 3.06-2.48H83C82.713 18.067 80.477 20 77.317 20c-3.76 0-6.208-2.62-6.208-6.942 0-4.247 2.448-6.93 6.183-6.93 3.385 0 5.446 2.213 5.683 4.845h-2.573zM10.824 3.189c-.698.834-1.805 1.496-2.913 1.398-.145-1.128.41-2.33 1.036-3.065C9.644.662 10.848.05 11.835 0c.121 1.178-.336 2.33-1.01 3.19zm.999 1.619c.624.049 2.425.244 3.578 1.98-.096.074-2.137 1.272-2.113 3.79.024 3.01 2.593 4.012 2.617 4.037-.024.074-.407 1.419-1.344 2.812-.817 1.224-1.657 2.422-3.002 2.447-1.297.024-1.73-.783-3.218-.783-1.489 0-1.97.758-3.194.807-1.297.048-2.28-1.297-3.097-2.52C.368 14.908-.904 10.408.825 7.375c.84-1.516 2.377-2.47 4.034-2.495 1.273-.023 2.45.857 3.218.857.769 0 2.137-1.027 3.746-.93z" fill-rule="nonzero" stroke="none" stroke-width="1" data-v-429d7b12=""></path>
<a data-v-429d7b12="" href="https://music.apple.com/buy-nao" target="_blank" title="Preview and buy this song on Apple Music"><svg data-v-429d7b12="" height="10" role="presentation" viewBox="0 0 83 20" width="41" xmlns="http://www.w3.org/2000/svg">
<path data-v-429d7b12="" d="M34.752 19.746V6.243h-.088l-5.433 13.503h-2.074L21.711 6.243h-.087v13.503h-2.548V1.399h3.235l5.833 14.621h.1L34.064 1.4h3.248v18.347h-2.56zm16.649 0h-2.586v-2.263h-.062c-.725 1.602-2.061 2.504-4.072 2.504-2.86 0-4.61-1.894-4.61-4.958V6.37h2.698v8.125c0 2.034.95 3.127 2.81 3.127 1.95 0 3.124-1.373 3.124-3.458V6.37H51.4v13.376zm7.394-13.618c3.06 0 5.046 1.73 5.134 4.196h-2.536c-.15-1.296-1.087-2.11-2.598-2.11-1.462 0-2.436.724-2.436 1.793 0 .839.6 1.41 2.023 1.741l2.136.496c2.686.636 3.71 1.704 3.71 3.636 0 2.442-2.236 4.12-5.333 4.12-3.285 0-5.26-1.64-5.509-4.183h2.673c.25 1.398 1.187 2.085 2.836 2.085 1.623 0 2.623-.687 2.623-1.78 0-.865-.487-1.373-1.924-1.704l-2.136-.508c-2.498-.585-3.735-1.806-3.735-3.75 0-2.391 2.049-4.032 5.072-4.032zM66.1 2.836c0-.878.7-1.577 1.561-1.577.862 0 1.55.7 1.55 1.577 0 .864-.688 1.576-1.55 1.576a1.573 1.573 0 0 1-1.56-1.576zm.212 3.534h2.698v13.376h-2.698V6.37zm14.089 4.603c-.275-1.424-1.324-2.556-3.085-2.556-2.086 0-3.46 1.767-3.46 4.64 0 2.938 1.386 4.642 3.485 4.642 1.66 0 2.748-.928 3.06-2.48H83C82.713 18.067 80.477 20 77.317 20c-3.76 0-6.208-2.62-6.208-6.942 0-4.247 2.448-6.93 6.183-6.93 3.385 0 5.446 2.213 5.683 4.845h-2.573zM10.824 3.189c-.698.834-1.805 1.496-2.913 1.398-.145-1.128.41-2.33 1.036-3.065C9.644.662 10.848.05 11.835 0c.121 1.178-.336 2.33-1.01 3.19zm.999 1.619c.624.049 2.425.244 3.578 1.98-.096.074-2.137 1.272-2.113 3.79.024 3.01 2.593 4.012 2.617 4.037-.024.074-.407 1.419-1.344 2.812-.817 1.224-1.657 2.422-3.002 2.447-1.297.024-1.73-.783-3.218-.783-1.489 0-1.97.758-3.194.807-1.297.048-2.28-1.297-3.097-2.52C.368 14.908-.904 10.408.825 7.375c.84-1.516 2.377-2.47 4.034-2.495 1.273-.023 2.45.857 3.218.857.769 0 2.137-1.027 3.746-.93z" fill-rule="nonzero" stroke="none" stroke-width="1"></path>
</svg></a>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<button type="button" data-v-e368fe26="">Click Me Nao</button>`;
exports[`renders 1`] = `<button data-v-e368fe26="">Click Me Nao</button>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<button data-testid="close-modal-btn" title="Dismiss" type="button" data-v-2d6954b5=""><br data-testid="icon" icon="[object Object]" data-v-2d6954b5=""></button>`;
exports[`renders 1`] = `<button data-v-2d6954b5="" data-testid="close-modal-btn" title="Dismiss" type="button"><br data-v-2d6954b5="" data-testid="icon" icon="[object Object]"></button>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<span class="btn-group" data-v-e884c19a=""><button type="button" data-v-e368fe26="">Green</button><button type="button" data-v-e368fe26="">Orange</button><button type="button" data-v-e368fe26="">Blue</button></span>`;
exports[`renders 1`] = `<span data-v-e884c19a="" class="btn-group"><button data-v-e368fe26="">Green</button><button data-v-e368fe26="">Orange</button><button data-v-e368fe26="">Blue</button></span>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<transition-stub name="fade" appear="false" persisted="true" css="true" data-v-7be58792=""><button title="Scroll to top" type="button" data-v-7be58792="" style="display: none;"><br data-testid="icon" icon="[object Object]" data-v-7be58792="">&nbsp; Top </button></transition-stub>`;
exports[`renders 1`] = `<transition-stub data-v-7be58792="" name="fade" appear="false" persisted="true" css="true"><button data-v-7be58792="" title="Scroll to top" type="button" style="display: none;"><br data-v-7be58792="" data-testid="icon" icon="[object Object]">&nbsp; Top </button></transition-stub>`;

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1
exports[`renders checked state 1`] = `<span data-v-b5259680=""><input type="checkbox" data-v-b5259680=""><br data-testid="icon" icon="[object Object]" data-v-b5259680=""></span>`;
exports[`renders checked state 1`] = `<span data-v-b5259680=""><input data-v-b5259680="" type="checkbox"><br data-v-b5259680="" data-testid="icon" icon="[object Object]"></span>`;
exports[`renders unchecked state 1`] = `<span data-v-b5259680=""><input type="checkbox" data-v-b5259680=""><!--v-if--></span>`;
exports[`renders unchecked state 1`] = `<span data-v-b5259680=""><input data-v-b5259680="" type="checkbox"><!--v-if--></span>`;

View file

@ -1,9 +1,9 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<article id="lyrics" data-v-22adf296="">
<div class="content" data-v-22adf296="">
<div data-v-22adf296=""><pre data-v-22adf296="" style="font-size: 1rem;">Foo bar baz qux</pre><span class="magnifier" data-v-fcc3eddd="" data-v-22adf296=""><button title="Zoom out" type="button" data-v-fcc3eddd=""><br data-testid="icon" icon="[object Object]" data-v-fcc3eddd=""></button><button title="Zoom in" type="button" data-v-fcc3eddd=""><br data-testid="icon" icon="[object Object]" data-v-fcc3eddd=""></button></span></div>
<article data-v-22adf296="" id="lyrics">
<div data-v-22adf296="" class="content">
<div data-v-22adf296=""><pre data-v-22adf296="" style="font-size: 1rem;">Foo bar baz qux</pre><span data-v-fcc3eddd="" data-v-22adf296="" class="magnifier"><button data-v-fcc3eddd="" title="Zoom out" type="button"><br data-v-fcc3eddd="" data-testid="icon" icon="[object Object]"></button><button data-v-fcc3eddd="" title="Zoom in" type="button"><br data-v-fcc3eddd="" data-testid="icon" icon="[object Object]"></button></span></div>
<!--v-if-->
</div>
</article>

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders and functions 1`] = `<span data-v-fcc3eddd=""><button title="Zoom out" type="button" data-v-fcc3eddd=""><br data-testid="icon" icon="[object Object]" data-v-fcc3eddd=""></button><button title="Zoom in" type="button" data-v-fcc3eddd=""><br data-testid="icon" icon="[object Object]" data-v-fcc3eddd=""></button></span>`;
exports[`renders and functions 1`] = `<span data-v-fcc3eddd=""><button data-v-fcc3eddd="" title="Zoom out" type="button"><br data-v-fcc3eddd="" data-testid="icon" icon="[object Object]"></button><button data-v-fcc3eddd="" title="Zoom in" type="button"><br data-v-fcc3eddd="" data-testid="icon" icon="[object Object]"></button></span>`;

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="message success" title="Click to dismiss" data-v-06be4cfe="">
<aside data-v-06be4cfe=""><br data-testid="icon" icon="[object Object]" class="icon" data-v-06be4cfe=""><br data-testid="icon" icon="[object Object]" class="icon-dismiss" data-v-06be4cfe=""></aside>
<div data-v-06be4cfe="" class="message success" title="Click to dismiss">
<aside data-v-06be4cfe=""><br data-v-06be4cfe="" data-testid="icon" icon="[object Object]" class="icon"><br data-v-06be4cfe="" data-testid="icon" icon="[object Object]" class="icon-dismiss"></aside>
<main data-v-06be4cfe=""></main>
</div>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences" data-v-663f2e50=""><img alt="Avatar of John Doe" src="https://example.com/avatar.jpg" data-v-663f2e50=""></a>`;
exports[`renders 1`] = `<a data-v-663f2e50="" class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences"><img data-v-663f2e50="" alt="Avatar of John Doe" src="https://example.com/avatar.jpg"></a>`;

View file

@ -1,11 +1,11 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5=""><img src="https://placekitten.com/200/300" data-v-5691beb5-s=""></aside>
<header data-v-5691beb5="" class="screen-header expanded">
<aside data-v-5691beb5="" class="thumbnail-wrapper"><img data-v-5691beb5-s="" src="https://placekitten.com/200/300"></aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5="">This Header</h1><span class="meta text-secondary" data-v-5691beb5=""><p data-v-5691beb5-s="">Some meta</p></span>
<div data-v-5691beb5="" class="heading-wrapper">
<h1 data-v-5691beb5="" class="name">This Header</h1><span data-v-5691beb5="" class="meta text-secondary"><p data-v-5691beb5-s="">Some meta</p></span>
</div>
<nav data-v-5691beb5-s="">Some controls</nav>
</main>

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1
exports[`renders list mode 1`] = `<span class="view-modes" data-v-d5772cd7=""><label class="thumbnails" data-testid="view-mode-thumbnail" title="View as thumbnails" data-v-d5772cd7=""><input class="hidden" name="view-mode" type="radio" value="thumbnails" data-v-d5772cd7=""><br data-testid="icon" icon="[object Object]" data-v-d5772cd7=""><span class="hidden" data-v-d5772cd7="">View as thumbnails</span></label><label class="active list" data-testid="view-mode-list" title="View as list" data-v-d5772cd7=""><input class="hidden" name="view-mode" type="radio" value="list" data-v-d5772cd7=""><br data-testid="icon" icon="[object Object]" data-v-d5772cd7=""><span class="hidden" data-v-d5772cd7="">View as list</span></label></span>`;
exports[`renders list mode 1`] = `<span data-v-d5772cd7="" class="view-modes"><label data-v-d5772cd7="" class="thumbnails" data-testid="view-mode-thumbnail" title="View as thumbnails"><input data-v-d5772cd7="" class="hidden" name="view-mode" type="radio" value="thumbnails"><br data-v-d5772cd7="" data-testid="icon" icon="[object Object]"><span data-v-d5772cd7="" class="hidden">View as thumbnails</span></label><label data-v-d5772cd7="" class="active list" data-testid="view-mode-list" title="View as list"><input data-v-d5772cd7="" class="hidden" name="view-mode" type="radio" value="list"><br data-v-d5772cd7="" data-testid="icon" icon="[object Object]"><span data-v-d5772cd7="" class="hidden">View as list</span></label></span>`;
exports[`renders thumbnails mode 1`] = `<span class="view-modes" data-v-d5772cd7=""><label class="active thumbnails" data-testid="view-mode-thumbnail" title="View as thumbnails" data-v-d5772cd7=""><input class="hidden" name="view-mode" type="radio" value="thumbnails" data-v-d5772cd7=""><br data-testid="icon" icon="[object Object]" data-v-d5772cd7=""><span class="hidden" data-v-d5772cd7="">View as thumbnails</span></label><label class="list" data-testid="view-mode-list" title="View as list" data-v-d5772cd7=""><input class="hidden" name="view-mode" type="radio" value="list" data-v-d5772cd7=""><br data-testid="icon" icon="[object Object]" data-v-d5772cd7=""><span class="hidden" data-v-d5772cd7="">View as list</span></label></span>`;
exports[`renders thumbnails mode 1`] = `<span data-v-d5772cd7="" class="view-modes"><label data-v-d5772cd7="" class="active thumbnails" data-testid="view-mode-thumbnail" title="View as thumbnails"><input data-v-d5772cd7="" class="hidden" name="view-mode" type="radio" value="thumbnails"><br data-v-d5772cd7="" data-testid="icon" icon="[object Object]"><span data-v-d5772cd7="" class="hidden">View as thumbnails</span></label><label data-v-d5772cd7="" class="list" data-testid="view-mode-list" title="View as list"><input data-v-d5772cd7="" class="hidden" name="view-mode" type="radio" value="list"><br data-v-d5772cd7="" data-testid="icon" icon="[object Object]"><span data-v-d5772cd7="" class="hidden">View as list</span></label></span>`;

View file

@ -1,10 +1,10 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<a href="https://youtu.be/cLgJQ8Zj3AA" data-testid="youtube-search-result" role="button" data-v-fd1eda6f=""><img alt="Guess what it is" src="https://i.ytimg.com/an_webp/cLgJQ8Zj3AA/mqdefault_6s.webp" width="90" data-v-fd1eda6f="">
<div class="meta" data-v-fd1eda6f="">
<h3 class="title" data-v-fd1eda6f="">Guess what it is</h3>
<p class="desc" data-v-fd1eda6f="">From the LA Opening Gala 2014: John Williams Celebration</p>
<a data-v-fd1eda6f="" href="https://youtu.be/cLgJQ8Zj3AA" data-testid="youtube-search-result" role="button"><img data-v-fd1eda6f="" alt="Guess what it is" src="https://i.ytimg.com/an_webp/cLgJQ8Zj3AA/mqdefault_6s.webp" width="90">
<div data-v-fd1eda6f="" class="meta">
<h3 data-v-fd1eda6f="" class="title">Guess what it is</h3>
<p data-v-fd1eda6f="" class="desc">From the LA Opening Gala 2014: John Williams Celebration</p>
</div>
</a>
`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1
exports[`renders 1`] = `<div class="canceled upload-item" title="" data-v-c7c7e2e2=""><span style="width: 42%;" class="progress" data-v-c7c7e2e2=""></span><span class="details" data-v-c7c7e2e2=""><span class="name" data-v-c7c7e2e2="">Sample Track</span><span class="controls" data-v-c7c7e2e2=""><button type="button" icon-only="" title="Retry" transparent="" unrounded="" data-v-e368fe26="" data-v-c7c7e2e2=""><br data-testid="icon" icon="[object Object]" data-v-c7c7e2e2=""></button><button type="button" icon-only="" title="Remove" transparent="" unrounded="" data-v-e368fe26="" data-v-c7c7e2e2=""><br data-testid="icon" icon="[object Object]" data-v-c7c7e2e2=""></button></span></span></div>`;
exports[`renders 1`] = `<div data-v-c7c7e2e2="" class="canceled upload-item" title=""><span data-v-c7c7e2e2="" style="width: 42%;" class="progress"></span><span data-v-c7c7e2e2="" class="details"><span data-v-c7c7e2e2="" class="name">Sample Track</span><span data-v-c7c7e2e2="" class="controls"><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Retry" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="icon" icon="[object Object]"></button><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Remove" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="icon" icon="[object Object]"></button></span></span></div>`;

View file

@ -0,0 +1,54 @@
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { screen, waitFor } from '@testing-library/vue'
import { MessageToasterStub } from '@/__tests__/stubs'
import { invitationService } from '@/services'
import InviteUserForm from './InviteUserForm.vue'
new class extends UnitTestCase {
protected test () {
it('invites single email', async () => {
const inviteMock = this.mock(invitationService, 'invite')
const alertMock = this.mock(MessageToasterStub.value, 'success')
this.render(InviteUserForm)
await this.type(screen.getByRole('textbox'), 'foo@bar.ai\n')
await this.user.click(screen.getByRole('checkbox'))
await this.user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => {
expect(inviteMock).toHaveBeenCalledWith(['foo@bar.ai'], true)
expect(alertMock).toHaveBeenCalledWith('Invitation sent.')
})
})
it('invites multiple emails', async () => {
const inviteMock = this.mock(invitationService, 'invite')
const alertMock = this.mock(MessageToasterStub.value, 'success')
this.render(InviteUserForm)
await this.type(screen.getByRole('textbox'), 'foo@bar.ai\n\na@b.c\n\n')
await this.user.click(screen.getByRole('checkbox'))
await this.user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => {
expect(inviteMock).toHaveBeenCalledWith(['foo@bar.ai', 'a@b.c'], true)
expect(alertMock).toHaveBeenCalledWith('Invitations sent.')
})
})
it('does not invites if at least one email is invalid', async () => {
const inviteMock = this.mock(invitationService, 'invite')
this.render(InviteUserForm)
await this.type(screen.getByRole('textbox'), 'invalid\n\na@b.c\n\n')
await this.user.click(screen.getByRole('checkbox'))
await this.user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => expect(inviteMock).not.toHaveBeenCalled())
})
}
}

View file

@ -0,0 +1,114 @@
<template>
<form novalidate @submit.prevent="submit" @keydown.esc="maybeClose">
<header>
<h1>Invite Users</h1>
</header>
<main>
<div class="form-row">
<label>
Emails
<small class="help">To invite multiple users, input one email per line.</small>
<textarea ref="emailsEl" v-model="rawEmails" name="emails" required title="Emails" />
</label>
</div>
<div class="form-row">
<label>
<CheckBox v-model="isAdmin" name="is_admin" />
Admin role
<TooltipIcon title="Admins can perform administrative tasks like managing users and uploading songs." />
</label>
</div>
</main>
<footer>
<Btn class="btn-add" type="submit">Invite</Btn>
<Btn class="btn-cancel" white @click.prevent="maybeClose">Cancel</Btn>
</footer>
</form>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { parseValidationError } from '@/utils'
import { useDialogBox, useMessageToaster, useOverlay } from '@/composables'
import { invitationService } from '@/services'
import Btn from '@/components/ui/Btn.vue'
import TooltipIcon from '@/components/ui/TooltipIcon.vue'
import CheckBox from '@/components/ui/CheckBox.vue'
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()
const { showErrorDialog, showConfirmDialog } = useDialogBox()
const emailsEl = ref<HTMLTextAreaElement>()
const rawEmails = ref('')
const isAdmin = ref(false)
let emailEntries: string[] = []
watch(rawEmails, val => {
emailEntries = val.trim().split('\n').map(email => email.trim()).filter(Boolean)
emailEntries = [...new Set(emailEntries)]
})
const submit = async () => {
const validEmails: string[] = []
const validator = document.createElement('input')
validator.type = 'email'
emailEntries.forEach(email => {
validator.value = email
validator.checkValidity() && validEmails.push(email)
})
if (validEmails.length !== emailEntries.length) {
emailsEl.value!.setCustomValidity('One or some of the emails you entered are invalid.')
emailsEl.value!.reportValidity()
return
}
if (validEmails.length === 0) {
emailsEl.value!.setCustomValidity('Please enter at least one email address.')
emailsEl.value!.reportValidity()
return
}
showOverlay()
try {
await invitationService.invite(validEmails, isAdmin.value)
toastSuccess(`Invitation${validEmails.length === 1 ? '' : 's'} sent.`)
close()
} catch (err: any) {
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
showErrorDialog(msg, 'Error')
} finally {
hideOverlay()
}
}
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {
if (emailEntries.length === 0 && !isAdmin.value) {
close()
return
}
await showConfirmDialog('Discard all changes?') && close()
}
</script>
<style lang="scss" scoped>
textarea {
min-height: 8rem !important;
}
small.help {
margin: .75rem 0 .5rem;
display: block;
}
</style>

View file

@ -6,6 +6,7 @@ import { eventBus } from '@/utils'
import { userStore } from '@/stores'
import { DialogBoxStub } from '@/__tests__/stubs'
import UserCard from './UserCard.vue'
import { invitationService } from '@/services'
new class extends UnitTestCase {
private renderComponent (user: User) {
@ -66,5 +67,27 @@ new class extends UnitTestCase {
expect(destroyMock).not.toHaveBeenCalled()
})
it('revokes invite for prospects', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(true)
const prospect = factory.states('prospect')<User>('user')
this.actingAsAdmin().renderComponent(prospect)
const revokeMock = this.mock(invitationService, 'revoke')
await this.user.click(screen.getByRole('button', { name: 'Revoke' }))
expect (revokeMock).toHaveBeenCalledWith(prospect)
})
it('does not revoke invite for prospects if not confirmed', async () => {
this.mock(DialogBoxStub.value, 'confirm').mockResolvedValue(false)
const prospect = factory.states('prospect')<User>('user')
this.actingAsAdmin().renderComponent(prospect)
const revokeMock = this.mock(invitationService, 'revoke')
await this.user.click(screen.getByRole('button', { name: 'Revoke' }))
expect(revokeMock).not.toHaveBeenCalled()
})
}
}

View file

@ -4,7 +4,8 @@
<main>
<h1>
<span class="name">{{ user.name }}</span>
<span v-if="user.name" class="name">{{ user.name }}</span>
<span v-else class="name anonymous">Anonymous</span>
<icon v-if="isCurrentUser" :icon="faCircleCheck" class="you text-highlight" title="This is you!" />
<icon
v-if="user.is_admin"
@ -17,12 +18,15 @@
<p class="email text-secondary">{{ user.email }}</p>
<footer>
<Btn class="btn-edit" orange small @click="edit">
{{ isCurrentUser ? 'Your Profile' : 'Edit' }}
</Btn>
<Btn v-if="!isCurrentUser" class="btn-delete" red small @click="confirmDelete">
Delete
</Btn>
<template v-if="user.is_prospect">
<Btn class="btn-revoke" red small @click="revokeInvite">Revoke</Btn>
</template>
<template v-else>
<Btn v-if="!isCurrentUser" class="btn-delete" red small @click="destroy">Delete</Btn>
<Btn v-if="!user.is_prospect" class="btn-edit" orange small @click="edit">
{{ isCurrentUser ? 'Your Profile' : 'Edit' }}
</Btn>
</template>
</footer>
</main>
</article>
@ -32,8 +36,9 @@
import { faCircleCheck, faShield } from '@fortawesome/free-solid-svg-icons'
import { computed, toRefs } from 'vue'
import { userStore } from '@/stores'
import { eventBus } from '@/utils'
import { invitationService } from '@/services'
import { useAuthorization, useDialogBox, useMessageToaster, useRouter } from '@/composables'
import { eventBus, parseValidationError } from '@/utils'
import Btn from '@/components/ui/Btn.vue'
@ -41,7 +46,7 @@ const props = defineProps<{ user: User }>()
const { user } = toRefs(props)
const { toastSuccess } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const { showConfirmDialog, showErrorDialog } = useDialogBox()
const { go } = useRouter()
const { currentUser } = useAuthorization()
@ -50,13 +55,29 @@ const isCurrentUser = computed(() => user.value.id === currentUser.value.id)
const edit = () => isCurrentUser.value ? go('profile') : eventBus.emit('MODAL_SHOW_EDIT_USER_FORM', user.value)
const confirmDelete = async () =>
await showConfirmDialog(`Youre about to unperson ${user.value.name}. Are you sure?`) && await destroy()
const destroy = async () => {
if (!await showConfirmDialog(`Unperson ${user.value.name}?`)) return
await userStore.destroy(user.value)
toastSuccess(`User "${user.value.name}" deleted.`)
}
const revokeInvite = async () => {
if (!await showConfirmDialog(`Revoke the invite for ${user.value.email}?`)) return
try {
await invitationService.revoke(user.value)
toastSuccess(`Invitation for ${user.value.email} revoked.`)
} catch (err: any) {
if (err.response.status === 404) {
showErrorDialog('Cannot revoke the invite. Maybe it has been accepted?', 'Revocation Failed')
return
}
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
showErrorDialog(msg, 'Error')
}
}
</script>
<style lang="scss" scoped>
@ -70,6 +91,11 @@ const destroy = async () => {
border: 1px solid var(--color-bg-secondary);
gap: 1rem;
.anonymous {
font-weight: var(--font-weight-light);
color: var(--color-text-secondary);
}
img {
border-radius: 50%;
flex: 0 0 80px;

View file

@ -12,6 +12,7 @@ export const useRouter = () => {
const isCurrentScreen = (...screens: ScreenName[]) => screens.includes(router.$currentRoute.value?.screen!)
const onScreenActivated = (screen: ScreenName, cb: Closure) => {
isCurrentScreen(screen) && cb()
router.onRouteChanged(route => route.screen === screen && cb())
}

View file

@ -9,9 +9,9 @@ import { useRouter } from '@/composables'
import {
SelectedSongsKey,
SongListConfigKey,
SongListFilterKeywordsKey,
SongListSortFieldKey,
SongListSortOrderKey,
SongListFilterKeywordsKey,
SongsKey
} from '@/symbols'

View file

@ -8,7 +8,7 @@ export const useSongListControls = () => {
play: true,
addTo: {
queue: true,
favorites: true,
favorites: true
},
clearQueue: false,
deletePlaylist: false,

View file

@ -18,6 +18,7 @@ export interface Events {
FULLSCREEN_TOGGLE: () => void
MODAL_SHOW_ADD_USER_FORM: () => void
MODAL_SHOW_INVITE_USER_FORM: () => void
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab?: EditSongFormTabName) => void
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, songs?: Song | Song[]) => void

View file

@ -46,17 +46,17 @@ export const routes: Route[] = [
{
path: '/upload',
screen: 'Upload',
onBeforeEnter: () => userStore.current.is_admin
onResolve: () => userStore.current?.is_admin
},
{
path: '/settings',
screen: 'Settings',
onBeforeEnter: () => userStore.current.is_admin
onResolve: () => userStore.current?.is_admin
},
{
path: '/users',
screen: 'Users',
onBeforeEnter: () => userStore.current.is_admin
onResolve: () => userStore.current?.is_admin
},
{
path: '/youtube',
@ -98,6 +98,10 @@ export const routes: Route[] = [
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
screen: 'Queue',
redirect: () => 'queue',
onBeforeEnter: params => eventBus.emit('SONG_QUEUED_FROM_ROUTE', params.id)
onResolve: params => eventBus.emit('SONG_QUEUED_FROM_ROUTE', params.id)
},
{
path: '/invitation/accept/(?<token>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
screen: 'Invitation.Accept'
}
]

View file

@ -1,8 +1,7 @@
import { ref, Ref, watch } from 'vue'
type RouteParams = Record<string, string>
type BeforeEnterHook = (params: RouteParams) => boolean | void
type EnterHook = (params: RouteParams) => any
type ResolveHook = (params: RouteParams) => boolean | void
type RedirectHook = (params: RouteParams) => Route | string
export type Route = {
@ -10,8 +9,7 @@ export type Route = {
screen: ScreenName
params?: RouteParams
redirect?: RedirectHook
onBeforeEnter?: BeforeEnterHook
onEnter?: EnterHook
onResolve?: ResolveHook
}
type RouteChangedHandler = (newRoute: Route, oldRoute: Route | undefined) => any
@ -46,7 +44,7 @@ export default class Router {
public async resolve () {
if (!location.hash || location.hash === '#/' || location.hash === '#!/') {
return this.activateRoute(this.homeRoute)
return this.go(this.homeRoute.path)
}
const matched = this.tryMatchRoute()
@ -56,7 +54,7 @@ export default class Router {
return this.triggerNotFound()
}
if (route.onBeforeEnter && route.onBeforeEnter(params) === false) {
if (route.onResolve?.(params) === false) {
return this.triggerNotFound()
}
@ -101,10 +99,6 @@ export default class Router {
public async activateRoute (route: Route, params: RouteParams = {}) {
this.$currentRoute.value = route
this.$currentRoute.value.params = params
if (this.$currentRoute.value.onEnter) {
await this.$currentRoute.value.onEnter(params)
}
}
public go (path: string) {

View file

@ -58,7 +58,7 @@ class Http {
this.client.interceptors.response.use(response => {
Http.hideProgressBar()
// …get the token from the header or response data if exists, and save it.
// …get the tokens from the header or response data if exist, and save them.
const token = response.headers.authorization || response.data.token
token && authService.setApiToken(token)

View file

@ -11,3 +11,4 @@ export * from './mediaInfoService'
export * from './cache'
export * from './socketListener'
export * from './volumeManager'
export * from './invitationService'

View file

@ -0,0 +1,17 @@
import { http } from '@/services'
import { userStore } from '@/stores'
export const invitationService = {
getUserProspect: async (token: string) => await http.get<User>(`invitations/?token=${token}`),
async accept (token: string, name: string, password: string) {
await http.post<User>('invitations/accept', { token, name, password })
},
invite: async (emails: string[], isAdmin: boolean) => {
const users = await http.post<User[]>('invitations', { emails, is_admin: isAdmin })
users.forEach(user => userStore.add(user))
},
revoke: async (user: User) => await http.delete(`invitations`, { email: user.email })
}

View file

@ -71,10 +71,14 @@ export const userStore = {
async store (data: CreateUserData) {
const user = await http.post<User>('users', data)
this.state.users.push(...this.syncWithVault(user))
this.add(user)
return this.byId(user.id)
},
add (user: User) {
this.state.users.push(...this.syncWithVault(user))
},
async update (user: User, data: UpdateUserData) {
this.syncWithVault(await http.put<User>(`users/${user.id}`, data))
},

View file

@ -242,6 +242,7 @@ interface User {
name: string
email: string
is_admin: boolean
is_prospect: boolean
password?: string
preferences?: UserPreferences
avatar: string
@ -310,6 +311,7 @@ declare type ScreenName =
| 'Upload'
| 'Search.Excerpt'
| 'Search.Songs'
| 'Invitation.Accept'
| '404'
declare type ArtistAlbumCardLayout = 'full' | 'compact'

View file

@ -155,7 +155,7 @@
}
@mixin inset-when-pressed() {
&:active {
&:not([disabled]):active {
box-shadow: inset 0px 10px 10px -10px rgba(0, 0, 0, .6);
}
}

View file

@ -61,6 +61,11 @@ textarea,
display: block;
}
&[disabled] {
opacity: .5;
cursor: not-allowed;
}
&:focus {
outline: none !important;
}

View file

@ -0,0 +1,12 @@
<x-mail::message>
Hey hey,
{{ $invitee->invitedBy->name }} has invited you to join them on {{ config('app.name') }}.
Click the button below to accept the invitation.
<x-mail::button :url="$url">
Accept Invitation
</x-mail::button>
Enjoy!
</x-mail::message>

View file

@ -38,6 +38,7 @@ use App\Http\Controllers\API\SongController;
use App\Http\Controllers\API\SongSearchController;
use App\Http\Controllers\API\UploadController;
use App\Http\Controllers\API\UserController;
use App\Http\Controllers\API\UserInvitationController;
use App\Http\Controllers\API\YouTubeController;
use App\Models\Song;
use Illuminate\Http\Request;
@ -50,7 +51,13 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::get('ping', static fn () => null);
Route::get('invitations', [UserInvitationController::class, 'get']);
Route::post('invitations', [UserInvitationController::class, 'invite']);
Route::post('invitations/accept', [UserInvitationController::class, 'accept']);
Route::middleware('auth')->group(static function (): void {
Route::delete('invitations', [UserInvitationController::class, 'revoke']);
Route::post('broadcasting/auth', static function (Request $request) {
$pusher = new Pusher(
config('broadcasting.connections.pusher.key'),

View file

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Mail\UserInvite;
use App\Models\User;
use Illuminate\Support\Str;
use Mail;
class UserInvitationTest extends TestCase
{
private const JSON_STRUCTURE = ['id', 'name', 'email', 'is_admin'];
public function testInvite(): void
{
Mail::fake();
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->postAs('api/invitations', [
'emails' => ['foo@bar.io', 'bar@baz.ai'],
'is_admin' => true,
], $admin)
->assertSuccessful()
->assertJsonStructure(['*' => self::JSON_STRUCTURE]);
Mail::assertQueued(UserInvite::class, 2);
}
public function testNonAdminCannotInvite(): void
{
Mail::fake();
/** @var User $admin */
$admin = User::factory()->create();
$this->postAs('api/invitations', [
'emails' => ['foo@bar.io', 'bar@baz.ai'],
], $admin)
->assertForbidden();
Mail::assertNothingQueued();
}
public function testGetProspect(): void
{
$prospect = self::createProspect();
$this->get("api/invitations?token=$prospect->invitation_token")
->assertSuccessful()
->assertJsonStructure(self::JSON_STRUCTURE);
}
public function testRevoke(): void
{
/** @var User $admin */
$admin = User::factory()->admin()->create();
$prospect = self::createProspect();
$this->deleteAs('api/invitations', ['email' => $prospect->email], $admin)
->assertSuccessful();
self::assertModelMissing($prospect);
}
public function testNonAdminCannotRevoke(): void
{
$prospect = self::createProspect();
$this->deleteAs('api/invitations', ['email' => $prospect->email])
->assertForbidden();
self::assertModelExists($prospect);
}
public function testAccept(): void
{
$prospect = self::createProspect();
$this->post('api/invitations/accept', [
'token' => $prospect->invitation_token,
'name' => 'Bruce Dickinson',
'password' => 'SuperSecretPassword',
])
->assertSuccessful()
->assertJsonStructure(['token', 'audio-token']);
$prospect->refresh();
self::assertFalse($prospect->is_prospect);
}
private static function createProspect(): User
{
/** @var User $admin */
$admin = User::factory()->admin()->create();
return User::factory()->for($admin, 'invitedBy')->create([
'invitation_token' => Str::uuid()->toString(),
'invited_at' => now(),
]);
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace Tests\Integration\Services;
use App\Exceptions\InvitationNotFoundException;
use App\Mail\UserInvite;
use App\Models\User;
use App\Services\UserInvitationService;
use Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\TestCase;
class UserInvitationServiceTest extends TestCase
{
private UserInvitationService $service;
public function setUp(): void
{
parent::setUp();
$this->service = app(UserInvitationService::class);
}
public function testInvite(): void
{
Mail::fake();
$emails = ['foo@bar.com', 'bar@baz.io'];
/** @var User $user */
$user = User::factory()->admin()->create();
$this->service
->invite($emails, true, $user)
->each(static function (User $prospect) use ($user): void {
self::assertTrue($prospect->is_admin);
self::assertTrue($prospect->invitedBy->is($user));
self::assertTrue($prospect->is_prospect);
self::assertNotNull($prospect->invitation_token);
self::assertNotNull($prospect->invited_at);
self::assertNull($prospect->invitation_accepted_at);
});
Mail::assertQueued(UserInvite::class, 2);
}
public function testGetUserProspectByToken(): void
{
$token = Str::uuid()->toString();
/** @var User $user */
$user = User::factory()->admin()->create();
$prospect = User::factory()->for($user, 'invitedBy')->create([
'invitation_token' => $token,
'invited_at' => now(),
]);
self::assertTrue($this->service->getUserProspectByToken($token)->is($prospect));
}
public function testGetUserProspectByTokenThrowsIfTokenNotFound(): void
{
$this->expectException(InvitationNotFoundException::class);
$this->service->getUserProspectByToken(Str::uuid()->toString());
}
public function testRevokeByEmail(): void
{
/** @var User $user */
$user = User::factory()->admin()->create();
/** @var User $prospect */
$prospect = User::factory()->for($user, 'invitedBy')->create([
'invitation_token' => Str::uuid()->toString(),
'invited_at' => now(),
]);
$this->service->revokeByEmail($prospect->email);
self::assertModelMissing($prospect);
}
public function testAccept(): void
{
$token = Str::uuid()->toString();
/** @var User $user */
$user = User::factory()->admin()->create();
User::factory()->for($user, 'invitedBy')->create([
'invitation_token' => $token,
'invited_at' => now(),
'is_admin' => true,
]);
$user = $this->service->accept($token, 'Bruce Dickinson', 'SuperSecretPassword');
self::assertFalse($user->is_prospect);
self::assertTrue($user->is_admin);
self::assertNull($user->invitation_token);
self::assertNotNull($user->invitation_accepted_at);
self::assertTrue(Hash::check('SuperSecretPassword', $user->password));
}
}

187
yarn.lock
View file

@ -262,11 +262,16 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/parser@^7.16.4", "@babel/parser@^7.18.10", "@babel/parser@^7.19.3":
"@babel/parser@^7.18.10", "@babel/parser@^7.19.3":
version "7.19.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.3.tgz#8dd36d17c53ff347f9e55c328710321b49479a9a"
integrity sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==
"@babel/parser@^7.20.15", "@babel/parser@^7.21.3":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
@ -1106,6 +1111,11 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/sourcemap-codec@^1.4.15":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@^0.3.14", "@jridgewell/trace-mapping@^0.3.9":
version "0.3.16"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.16.tgz#a7982f16c18cae02be36274365433e5b49d7b23f"
@ -1438,95 +1448,95 @@
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.1.2.tgz#3cd52114e8871a0b5e7bd7d837469c032e503036"
integrity sha512-3zxKNlvA3oNaKDYX0NBclgxTQ1xaFdL7PzwF6zj9tGFziKwmBa3Q/6XcJQxudlT81WxDjEhHmevvIC4Orc1LhQ==
"@vue/compiler-core@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.40.tgz#c785501f09536748121e937fb87605bbb1ada8e5"
integrity sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==
"@vue/compiler-core@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.3.4.tgz#7fbf591c1c19e1acd28ffd284526e98b4f581128"
integrity sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/shared" "3.2.40"
"@babel/parser" "^7.21.3"
"@vue/shared" "3.3.4"
estree-walker "^2.0.2"
source-map "^0.6.1"
source-map-js "^1.0.2"
"@vue/compiler-dom@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz#c225418773774db536174d30d3f25ba42a33e7e4"
integrity sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==
"@vue/compiler-dom@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz#f56e09b5f4d7dc350f981784de9713d823341151"
integrity sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==
dependencies:
"@vue/compiler-core" "3.2.40"
"@vue/shared" "3.2.40"
"@vue/compiler-core" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/compiler-sfc@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz#61823283efc84d25d9d2989458f305d32a2ed141"
integrity sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==
"@vue/compiler-sfc@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz#b19d942c71938893535b46226d602720593001df"
integrity sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.40"
"@vue/compiler-dom" "3.2.40"
"@vue/compiler-ssr" "3.2.40"
"@vue/reactivity-transform" "3.2.40"
"@vue/shared" "3.2.40"
"@babel/parser" "^7.20.15"
"@vue/compiler-core" "3.3.4"
"@vue/compiler-dom" "3.3.4"
"@vue/compiler-ssr" "3.3.4"
"@vue/reactivity-transform" "3.3.4"
"@vue/shared" "3.3.4"
estree-walker "^2.0.2"
magic-string "^0.25.7"
magic-string "^0.30.0"
postcss "^8.1.10"
source-map "^0.6.1"
source-map-js "^1.0.2"
"@vue/compiler-ssr@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz#67df95a096c63e9ec4b50b84cc6f05816793629c"
integrity sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==
"@vue/compiler-ssr@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz#9d1379abffa4f2b0cd844174ceec4a9721138777"
integrity sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==
dependencies:
"@vue/compiler-dom" "3.2.40"
"@vue/shared" "3.2.40"
"@vue/compiler-dom" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/reactivity-transform@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz#dc24b9074b26f0d9dd2034c6349f5bb2a51c86ac"
integrity sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==
"@vue/reactivity-transform@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz#52908476e34d6a65c6c21cd2722d41ed8ae51929"
integrity sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.40"
"@vue/shared" "3.2.40"
"@babel/parser" "^7.20.15"
"@vue/compiler-core" "3.3.4"
"@vue/shared" "3.3.4"
estree-walker "^2.0.2"
magic-string "^0.25.7"
magic-string "^0.30.0"
"@vue/reactivity@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.40.tgz#ae65496f5b364e4e481c426f391568ed7d133cca"
integrity sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA==
"@vue/reactivity@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.3.4.tgz#a27a29c6cd17faba5a0e99fbb86ee951653e2253"
integrity sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==
dependencies:
"@vue/shared" "3.2.40"
"@vue/shared" "3.3.4"
"@vue/runtime-core@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.40.tgz#e814358bf1b0ff6d4a6b4f8f62d9f341964fb275"
integrity sha512-U1+rWf0H8xK8aBUZhnrN97yoZfHbjgw/bGUzfgKPJl69/mXDuSg8CbdBYBn6VVQdR947vWneQBFzdhasyzMUKg==
"@vue/runtime-core@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.3.4.tgz#4bb33872bbb583721b340f3088888394195967d1"
integrity sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==
dependencies:
"@vue/reactivity" "3.2.40"
"@vue/shared" "3.2.40"
"@vue/reactivity" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/runtime-dom@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.40.tgz#975119feac5ab703aa9bbbf37c9cc966602c8eab"
integrity sha512-AO2HMQ+0s2+MCec8hXAhxMgWhFhOPJ/CyRXnmTJ6XIOnJFLrH5Iq3TNwvVcODGR295jy77I6dWPj+wvFoSYaww==
"@vue/runtime-dom@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz#992f2579d0ed6ce961f47bbe9bfe4b6791251566"
integrity sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==
dependencies:
"@vue/runtime-core" "3.2.40"
"@vue/shared" "3.2.40"
csstype "^2.6.8"
"@vue/runtime-core" "3.3.4"
"@vue/shared" "3.3.4"
csstype "^3.1.1"
"@vue/server-renderer@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.40.tgz#55eaac31f7105c3907e1895129bf4efb6b0ce393"
integrity sha512-gtUcpRwrXOJPJ4qyBpU3EyxQa4EkV8I4f8VrDePcGCPe4O/hd0BPS7v9OgjIQob6Ap8VDz9G+mGTKazE45/95w==
"@vue/server-renderer@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.3.4.tgz#ea46594b795d1536f29bc592dd0f6655f7ea4c4c"
integrity sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==
dependencies:
"@vue/compiler-ssr" "3.2.40"
"@vue/shared" "3.2.40"
"@vue/compiler-ssr" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/shared@3.2.40":
version "3.2.40"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.40.tgz#e57799da2a930b975321981fcee3d1e90ed257ae"
integrity sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ==
"@vue/shared@3.3.4":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.3.4.tgz#06e83c5027f464eef861c329be81454bc8b70780"
integrity sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==
"@vue/test-utils@^2.0.0":
version "2.2.4"
@ -2570,10 +2580,10 @@ cssstyle@^2.3.0:
dependencies:
cssom "~0.3.6"
csstype@^2.6.8:
version "2.6.21"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
csstype@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
cypress@^9.5.4:
version "9.7.0"
@ -4646,12 +4656,12 @@ lz-string@^1.4.4:
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==
magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
magic-string@^0.30.0:
version "0.30.3"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.3.tgz#403755dfd9d6b398dfa40635d52e96c5ac095b85"
integrity sha512-B7xGbll2fG/VjP+SWg4sX3JynwIU0mjoTc6MPpKNuIvftk6u6vqhDnk1R80b8C2GBR6ywqy+1DcKBrevBg+bmw==
dependencies:
sourcemap-codec "^1.4.8"
"@jridgewell/sourcemap-codec" "^1.4.15"
map-stream@~0.1.0:
version "0.1.0"
@ -5912,11 +5922,6 @@ source-map@^0.5.3, source-map@^0.5.6:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
sourcemap-codec@^1.4.8:
version "1.4.8"
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
split@0.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f"
@ -6496,16 +6501,16 @@ vue-loader@^16.2.0:
hash-sum "^2.0.0"
loader-utils "^2.0.0"
vue@^3.2.32:
version "3.2.40"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.40.tgz#23f387f6f9b3a0767938db6751e4fb5900f0ee34"
integrity sha512-1mGHulzUbl2Nk3pfvI5aXYYyJUs1nm4kyvuz38u4xlQkLUn1i2R7nDbI4TufECmY8v1qNBHYy62bCaM+3cHP2A==
vue@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.3.4.tgz#8ed945d3873667df1d0fcf3b2463ada028f88bd6"
integrity sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==
dependencies:
"@vue/compiler-dom" "3.2.40"
"@vue/compiler-sfc" "3.2.40"
"@vue/runtime-dom" "3.2.40"
"@vue/server-renderer" "3.2.40"
"@vue/shared" "3.2.40"
"@vue/compiler-dom" "3.3.4"
"@vue/compiler-sfc" "3.3.4"
"@vue/runtime-dom" "3.3.4"
"@vue/server-renderer" "3.3.4"
"@vue/shared" "3.3.4"
w3c-hr-time@^1.0.2:
version "1.0.2"