mirror of
https://github.com/koel/koel
synced 2024-09-19 22:02:00 +00:00
feat: invite users
This commit is contained in:
parent
c382d5799e
commit
f87d970b50
100 changed files with 1957 additions and 728 deletions
|
@ -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=
|
||||
|
|
9
app/Exceptions/InvalidCredentialsException.php
Normal file
9
app/Exceptions/InvalidCredentialsException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class InvalidCredentialsException extends Exception
|
||||
{
|
||||
}
|
9
app/Exceptions/InvitationNotFoundException.php
Normal file
9
app/Exceptions/InvitationNotFoundException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class InvitationNotFoundException extends Exception
|
||||
{
|
||||
}
|
9
app/Exceptions/UserProspectUpdateDeniedException.php
Normal file
9
app/Exceptions/UserProspectUpdateDeniedException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use LogicException;
|
||||
|
||||
class UserProspectUpdateDeniedException extends LogicException
|
||||
{
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
75
app/Http/Controllers/API/UserInvitationController.php
Normal file
75
app/Http/Controllers/API/UserInvitationController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
23
app/Http/Requests/API/AcceptUserInvitationRequest.php
Normal file
23
app/Http/Requests/API/AcceptUserInvitationRequest.php
Normal 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()],
|
||||
];
|
||||
}
|
||||
}
|
19
app/Http/Requests/API/GetUserInvitationRequest.php
Normal file
19
app/Http/Requests/API/GetUserInvitationRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
30
app/Http/Requests/API/InviteUserRequest.php
Normal file
30
app/Http/Requests/API/InviteUserRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
15
app/Http/Requests/API/RevokeUserInvitationRequest.php
Normal file
15
app/Http/Requests/API/RevokeUserInvitationRequest.php
Normal 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'];
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
36
app/Mail/UserInvite.php
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
36
app/Services/AuthenticationService.php
Normal file
36
app/Services/AuthenticationService.php
Normal 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);
|
||||
}
|
||||
}
|
77
app/Services/UserInvitationService.php
Normal file
77
app/Services/UserInvitationService.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
],
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
126
resources/assets/js/components/invitation/AcceptInvitation.vue
Normal file
126
resources/assets/js/components/invitation/AcceptInvitation.vue
Normal 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 & 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>
|
|
@ -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'),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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> <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> <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>
|
||||
`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
|||
|
||||
this.router.activateRoute({
|
||||
screen,
|
||||
path: '_',
|
||||
path: '_'
|
||||
})
|
||||
|
||||
return this.render(SongListControls, {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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&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 & 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 & 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 & 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&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 & 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 & 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 & 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&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 & 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 & 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 & 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&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 & 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 & 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 & 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>
|
||||
`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,6 @@ svg {
|
|||
color: var(--color-highlight);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 2px;
|
||||
left: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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'],
|
||||
|
|
48
resources/assets/js/components/ui/PasswordField.vue
Normal file
48
resources/assets/js/components/ui/PasswordField.vue
Normal 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>
|
|
@ -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>`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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=""> 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]"> Top </button></transition-stub>`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
54
resources/assets/js/components/user/InviteUserForm.spec.ts
Normal file
54
resources/assets/js/components/user/InviteUserForm.spec.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
114
resources/assets/js/components/user/InviteUserForm.vue
Normal file
114
resources/assets/js/components/user/InviteUserForm.vue
Normal 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>
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(`You’re 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;
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -9,9 +9,9 @@ import { useRouter } from '@/composables'
|
|||
import {
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListFilterKeywordsKey,
|
||||
SongListSortFieldKey,
|
||||
SongListSortOrderKey,
|
||||
SongListFilterKeywordsKey,
|
||||
SongsKey
|
||||
} from '@/symbols'
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export const useSongListControls = () => {
|
|||
play: true,
|
||||
addTo: {
|
||||
queue: true,
|
||||
favorites: true,
|
||||
favorites: true
|
||||
},
|
||||
clearQueue: false,
|
||||
deletePlaylist: false,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -11,3 +11,4 @@ export * from './mediaInfoService'
|
|||
export * from './cache'
|
||||
export * from './socketListener'
|
||||
export * from './volumeManager'
|
||||
export * from './invitationService'
|
||||
|
|
17
resources/assets/js/services/invitationService.ts
Normal file
17
resources/assets/js/services/invitationService.ts
Normal 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 })
|
||||
}
|
|
@ -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))
|
||||
},
|
||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -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'
|
||||
|
|
|
@ -155,7 +155,7 @@
|
|||
}
|
||||
|
||||
@mixin inset-when-pressed() {
|
||||
&:active {
|
||||
&:not([disabled]):active {
|
||||
box-shadow: inset 0px 10px 10px -10px rgba(0, 0, 0, .6);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,11 @@ textarea,
|
|||
display: block;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
|
12
resources/views/emails/users/invite.blade.php
Normal file
12
resources/views/emails/users/invite.blade.php
Normal 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>
|
|
@ -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'),
|
||||
|
|
105
tests/Feature/UserInvitationTest.php
Normal file
105
tests/Feature/UserInvitationTest.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
106
tests/Integration/Services/UserInvitationServiceTest.php
Normal file
106
tests/Integration/Services/UserInvitationServiceTest.php
Normal 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
187
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue