mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: custom profile avatar
This commit is contained in:
parent
91319d6724
commit
e106bff23d
58 changed files with 597 additions and 2773 deletions
|
@ -4,5 +4,5 @@ trim_trailing_whitespace = true
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[{*.php, *.xml, *.xml.dist}]
|
[{*.php,*.xml,*.xml.dist}]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Illuminate\Support\Facades\File as FileFacade;
|
use Illuminate\Support\Facades\File as FileFacade;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a URL for static file requests.
|
* Get a URL for static file requests.
|
||||||
|
@ -47,6 +48,16 @@ function playlist_cover_url(?string $fileName): ?string
|
||||||
return $fileName ? static_url(config('koel.playlist_cover_dir') . $fileName) : null;
|
return $fileName ? static_url(config('koel.playlist_cover_dir') . $fileName) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function user_avatar_path(?string $fileName): ?string
|
||||||
|
{
|
||||||
|
return $fileName ? public_path(config('koel.user_avatar_dir') . $fileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function user_avatar_url(?string $fileName): ?string
|
||||||
|
{
|
||||||
|
return $fileName ? static_url(config('koel.user_avatar_dir') . $fileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function koel_version(): string
|
function koel_version(): string
|
||||||
{
|
{
|
||||||
return trim(FileFacade::get(base_path('.version')));
|
return trim(FileFacade::get(base_path('.version')));
|
||||||
|
@ -84,7 +95,7 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
|
||||||
|
|
||||||
function gravatar(string $email, int $size = 192): string
|
function gravatar(string $email, int $size = 192): string
|
||||||
{
|
{
|
||||||
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email));
|
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5(Str::lower($email)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -7,9 +7,11 @@ use App\Http\Requests\API\ProfileUpdateRequest;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\TokenManager;
|
use App\Services\TokenManager;
|
||||||
|
use App\Services\UserService;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Hashing\Hasher;
|
use Illuminate\Contracts\Hashing\Hasher;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
|
@ -17,6 +19,7 @@ class ProfileController extends Controller
|
||||||
/** @param User $user */
|
/** @param User $user */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Hasher $hash,
|
private Hasher $hash,
|
||||||
|
private UserService $userService,
|
||||||
private TokenManager $tokenManager,
|
private TokenManager $tokenManager,
|
||||||
private ?Authenticatable $user
|
private ?Authenticatable $user
|
||||||
) {
|
) {
|
||||||
|
@ -36,15 +39,15 @@ class ProfileController extends Controller
|
||||||
ValidationException::withMessages(['current_password' => 'Invalid current password'])
|
ValidationException::withMessages(['current_password' => 'Invalid current password'])
|
||||||
);
|
);
|
||||||
|
|
||||||
$data = $request->only('name', 'email');
|
$user = $this->userService->updateUser(
|
||||||
|
user: $this->user,
|
||||||
|
name: $request->name,
|
||||||
|
email: $request->email,
|
||||||
|
password: $request->new_password,
|
||||||
|
avatar: Str::startsWith($request->avatar, 'data:') ? $request->avatar : null
|
||||||
|
);
|
||||||
|
|
||||||
if ($request->new_password) {
|
$response = UserResource::make($user)->response();
|
||||||
$data['password'] = $this->hash->make($request->new_password);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->user->update($data);
|
|
||||||
|
|
||||||
$response = UserResource::make($this->user)->response();
|
|
||||||
|
|
||||||
if ($request->new_password) {
|
if ($request->new_password) {
|
||||||
$response->header(
|
$response->header(
|
||||||
|
|
|
@ -9,15 +9,10 @@ use App\Services\MediaMetadataService;
|
||||||
|
|
||||||
class UploadAlbumCoverController extends Controller
|
class UploadAlbumCoverController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(UploadAlbumCoverRequest $request, Album $album, MediaMetadataService $mediaMetadataService)
|
public function __invoke(UploadAlbumCoverRequest $request, Album $album, MediaMetadataService $metadataService)
|
||||||
{
|
{
|
||||||
$this->authorize('update', $album);
|
$this->authorize('update', $album);
|
||||||
|
$metadataService->writeAlbumCover($album, $request->getFileContent());
|
||||||
$mediaMetadataService->writeAlbumCover(
|
|
||||||
$album,
|
|
||||||
$request->getFileContentAsBinaryString(),
|
|
||||||
$request->getFileExtension()
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['cover_url' => $album->cover]);
|
return response()->json(['cover_url' => $album->cover]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,18 +9,10 @@ use App\Services\MediaMetadataService;
|
||||||
|
|
||||||
class UploadArtistImageController extends Controller
|
class UploadArtistImageController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(
|
public function __invoke(UploadArtistImageRequest $request, Artist $artist, MediaMetadataService $metadataService)
|
||||||
UploadArtistImageRequest $request,
|
{
|
||||||
Artist $artist,
|
|
||||||
MediaMetadataService $mediaMetadataService
|
|
||||||
) {
|
|
||||||
$this->authorize('update', $artist);
|
$this->authorize('update', $artist);
|
||||||
|
$metadataService->writeArtistImage($artist, $request->getFileContent());
|
||||||
$mediaMetadataService->writeArtistImage(
|
|
||||||
$artist,
|
|
||||||
$request->getFileContentAsBinaryString(),
|
|
||||||
$request->getFileExtension()
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['image_url' => $artist->image]);
|
return response()->json(['image_url' => $artist->image]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,12 +15,7 @@ class UploadPlaylistCoverController extends Controller
|
||||||
MediaMetadataService $mediaMetadataService
|
MediaMetadataService $mediaMetadataService
|
||||||
) {
|
) {
|
||||||
$this->authorize('collaborate', $playlist);
|
$this->authorize('collaborate', $playlist);
|
||||||
|
$mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent());
|
||||||
$mediaMetadataService->writePlaylistCover(
|
|
||||||
$playlist,
|
|
||||||
$request->getFileContentAsBinaryString(),
|
|
||||||
$request->getFileExtension()
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['cover_url' => $playlist->cover]);
|
return response()->json(['cover_url' => $playlist->cover]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,11 +43,11 @@ class UserController extends Controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return UserResource::make($this->userService->updateUser(
|
return UserResource::make($this->userService->updateUser(
|
||||||
$user,
|
user: $user,
|
||||||
$request->name,
|
name: $request->name,
|
||||||
$request->email,
|
email: $request->email,
|
||||||
$request->password,
|
password: $request->password,
|
||||||
$request->get('is_admin') ?: false
|
isAdmin: $request->get('is_admin') ?: false
|
||||||
));
|
));
|
||||||
} catch (UserProspectUpdateDeniedException) {
|
} catch (UserProspectUpdateDeniedException) {
|
||||||
abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.');
|
abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.');
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Http\Requests\API;
|
namespace App\Http\Requests\API;
|
||||||
|
|
||||||
use App\Rules\ImageData;
|
use App\Rules\ImageData;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
abstract class MediaImageUpdateRequest extends Request
|
abstract class MediaImageUpdateRequest extends Request
|
||||||
{
|
{
|
||||||
|
@ -15,14 +14,9 @@ abstract class MediaImageUpdateRequest extends Request
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFileContentAsBinaryString(): string
|
public function getFileContent(): string
|
||||||
{
|
{
|
||||||
return base64_decode(Str::after($this->{$this->getImageFieldName()}, ','), true);
|
return $this->{$this->getImageFieldName()};
|
||||||
}
|
|
||||||
|
|
||||||
public function getFileExtension(): string
|
|
||||||
{
|
|
||||||
return Str::after(Str::before($this->{$this->getImageFieldName()}, ';'), '/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract protected function getImageFieldName(): string;
|
abstract protected function getImageFieldName(): string;
|
||||||
|
|
|
@ -7,6 +7,9 @@ use Illuminate\Validation\Rules\Password;
|
||||||
/**
|
/**
|
||||||
* @property-read string|null $current_password
|
* @property-read string|null $current_password
|
||||||
* @property-read string|null $new_password
|
* @property-read string|null $new_password
|
||||||
|
* @property-read string $name
|
||||||
|
* @property-read string $email
|
||||||
|
* @property-read string|null $avatar
|
||||||
*/
|
*/
|
||||||
class ProfileUpdateRequest extends Request
|
class ProfileUpdateRequest extends Request
|
||||||
{
|
{
|
||||||
|
|
|
@ -76,7 +76,13 @@ class User extends Authenticatable
|
||||||
|
|
||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(fn (): string => gravatar($this->email));
|
return Attribute::get(function (): string {
|
||||||
|
if ($this->attributes['avatar']) {
|
||||||
|
return user_avatar_url($this->attributes['avatar']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gravatar($this->email);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function isProspect(): Attribute
|
protected function isProspect(): Attribute
|
||||||
|
|
|
@ -115,10 +115,7 @@ class FileScanner
|
||||||
attempt(function () use ($album, $coverData): void {
|
attempt(function () use ($album, $coverData): void {
|
||||||
// If the album has no cover, we try to get the cover image from existing tag data
|
// If the album has no cover, we try to get the cover image from existing tag data
|
||||||
if ($coverData) {
|
if ($coverData) {
|
||||||
$extension = explode('/', $coverData['image_mime']);
|
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data']);
|
||||||
$extension = $extension[1] ?? 'png';
|
|
||||||
|
|
||||||
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -127,8 +124,7 @@ class FileScanner
|
||||||
$cover = $this->getCoverFileUnderSameDirectory();
|
$cover = $this->getCoverFileUnderSameDirectory();
|
||||||
|
|
||||||
if ($cover) {
|
if ($cover) {
|
||||||
$extension = pathinfo($cover, PATHINFO_EXTENSION);
|
$this->mediaMetadataService->writeAlbumCover($album, $cover);
|
||||||
$this->mediaMetadataService->writeAlbumCover($album, $cover, $extension);
|
|
||||||
}
|
}
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,16 +27,10 @@ class MediaMetadataService
|
||||||
* @param string $source Path, URL, or even binary data. See https://image.intervention.io/v2/api/make.
|
* @param string $source Path, URL, or even binary data. See https://image.intervention.io/v2/api/make.
|
||||||
* @param string|null $destination The destination path. Automatically generated if empty.
|
* @param string|null $destination The destination path. Automatically generated if empty.
|
||||||
*/
|
*/
|
||||||
public function writeAlbumCover(
|
public function writeAlbumCover(Album $album, string $source, ?string $destination = '', bool $cleanUp = true): void
|
||||||
Album $album,
|
{
|
||||||
string $source,
|
attempt(function () use ($album, $source, $destination, $cleanUp): void {
|
||||||
string $extension = 'png',
|
$destination = $destination ?: $this->generateAlbumCoverPath();
|
||||||
?string $destination = '',
|
|
||||||
bool $cleanUp = true
|
|
||||||
): void {
|
|
||||||
attempt(function () use ($album, $source, $extension, $destination, $cleanUp): void {
|
|
||||||
$extension = trim(strtolower($extension), '. ');
|
|
||||||
$destination = $destination ?: $this->generateAlbumCoverPath($extension);
|
|
||||||
$this->imageWriter->write($destination, $source);
|
$this->imageWriter->write($destination, $source);
|
||||||
|
|
||||||
if ($cleanUp) {
|
if ($cleanUp) {
|
||||||
|
@ -64,13 +58,11 @@ class MediaMetadataService
|
||||||
public function writeArtistImage(
|
public function writeArtistImage(
|
||||||
Artist $artist,
|
Artist $artist,
|
||||||
string $source,
|
string $source,
|
||||||
string $extension = 'png',
|
|
||||||
?string $destination = '',
|
?string $destination = '',
|
||||||
bool $cleanUp = true
|
bool $cleanUp = true
|
||||||
): void {
|
): void {
|
||||||
attempt(function () use ($artist, $source, $extension, $destination, $cleanUp): void {
|
attempt(function () use ($artist, $source, $destination, $cleanUp): void {
|
||||||
$extension = trim(strtolower($extension), '. ');
|
$destination = $destination ?: $this->generateArtistImagePath();
|
||||||
$destination = $destination ?: $this->generateArtistImagePath($extension);
|
|
||||||
$this->imageWriter->write($destination, $source);
|
$this->imageWriter->write($destination, $source);
|
||||||
|
|
||||||
if ($cleanUp && $artist->has_image) {
|
if ($cleanUp && $artist->has_image) {
|
||||||
|
@ -81,11 +73,10 @@ class MediaMetadataService
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function writePlaylistCover(Playlist $playlist, string $source, string $extension = 'png'): void
|
public function writePlaylistCover(Playlist $playlist, string $source): void
|
||||||
{
|
{
|
||||||
attempt(function () use ($playlist, $source, $extension): void {
|
attempt(function () use ($playlist, $source): void {
|
||||||
$extension = trim(strtolower($extension), '. ');
|
$destination = $this->generatePlaylistCoverPath();
|
||||||
$destination = $this->generatePlaylistCoverPath($extension);
|
|
||||||
$this->imageWriter->write($destination, $source);
|
$this->imageWriter->write($destination, $source);
|
||||||
|
|
||||||
if ($playlist->cover_path) {
|
if ($playlist->cover_path) {
|
||||||
|
@ -96,19 +87,19 @@ class MediaMetadataService
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateAlbumCoverPath(string $extension): string
|
private function generateAlbumCoverPath(): string
|
||||||
{
|
{
|
||||||
return album_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
return album_cover_path(sprintf('%s.webp', sha1(Str::uuid())));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateArtistImagePath(string $extension): string
|
private function generateArtistImagePath(): string
|
||||||
{
|
{
|
||||||
return artist_image_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
return artist_image_path(sprintf('%s.webp', sha1(Str::uuid())));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generatePlaylistCoverPath(string $extension): string
|
private function generatePlaylistCoverPath(): string
|
||||||
{
|
{
|
||||||
return playlist_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
return playlist_cover_path(sprintf('%s.webp', sha1(Str::uuid())));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -55,11 +55,7 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
||||||
$album = Album::getOrCreate($albumArtist, $albumName);
|
$album = Album::getOrCreate($albumArtist, $albumName);
|
||||||
|
|
||||||
if ($cover) {
|
if ($cover) {
|
||||||
$this->mediaMetadataService->writeAlbumCover(
|
$this->mediaMetadataService->writeAlbumCover($album, base64_decode($cover['data'], true));
|
||||||
$album,
|
|
||||||
base64_decode($cover['data'], true),
|
|
||||||
$cover['extension']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Song $song */
|
/** @var Song $song */
|
||||||
|
|
|
@ -5,10 +5,12 @@ namespace App\Services;
|
||||||
use App\Exceptions\UserProspectUpdateDeniedException;
|
use App\Exceptions\UserProspectUpdateDeniedException;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Hashing\Hasher;
|
use Illuminate\Contracts\Hashing\Hasher;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserService
|
class UserService
|
||||||
{
|
{
|
||||||
public function __construct(private Hasher $hash)
|
public function __construct(private Hasher $hash, private ImageWriter $imageWriter)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,20 +24,43 @@ class UserService
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User
|
public function updateUser(
|
||||||
{
|
User $user,
|
||||||
|
string $name,
|
||||||
|
string $email,
|
||||||
|
?string $password,
|
||||||
|
?bool $isAdmin = null,
|
||||||
|
?string $avatar = null
|
||||||
|
): User {
|
||||||
throw_if($user->is_prospect, new UserProspectUpdateDeniedException());
|
throw_if($user->is_prospect, new UserProspectUpdateDeniedException());
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'is_admin' => $isAdmin,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($isAdmin !== null) {
|
||||||
|
$data['is_admin'] = $isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
if ($password) {
|
if ($password) {
|
||||||
$data['password'] = $this->hash->make($password);
|
$data['password'] = $this->hash->make($password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($avatar) {
|
||||||
|
$oldAvatar = $user->getRawOriginal('avatar');
|
||||||
|
|
||||||
|
$path = self::generateUserAvatarPath();
|
||||||
|
$this->imageWriter->write($path, $avatar, ['max_width' => 480]);
|
||||||
|
$data['avatar'] = basename($path);
|
||||||
|
|
||||||
|
if ($oldAvatar) {
|
||||||
|
File::delete($oldAvatar);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$data['avatar'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
$user->update($data);
|
$user->update($data);
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
@ -52,4 +77,9 @@ class UserService
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function generateUserAvatarPath(): string
|
||||||
|
{
|
||||||
|
return user_avatar_path(sprintf('%s.webp', sha1(Str::uuid())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,9 @@ return [
|
||||||
// The *relative* path to the directory to store playlist covers, *with* a trailing slash.
|
// The *relative* path to the directory to store playlist covers, *with* a trailing slash.
|
||||||
'playlist_cover_dir' => 'img/playlists/',
|
'playlist_cover_dir' => 'img/playlists/',
|
||||||
|
|
||||||
|
// The *relative* path to the directory to store user avatars, *with* a trailing slash.
|
||||||
|
'user_avatar_dir' => 'img/avatars/',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Sync Options
|
| Sync Options
|
||||||
|
|
15
database/migrations/2024_03_19_204549_add_user_avatar.php
Normal file
15
database/migrations/2024_03_19_204549_add_user_avatar.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', static function (Blueprint $table): void {
|
||||||
|
$table->string('avatar')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
1
docs/assets/icons/times.svg
Normal file
1
docs/assets/icons/times.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"></path></svg>
|
After Width: | Height: | Size: 394 B |
1
docs/assets/icons/upload.svg
Normal file
1
docs/assets/icons/upload.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456c13.3 0 24-10.7 24-24s-10.7-24-24-24s-24 10.7-24 24s10.7 24 24 24z"></path></svg>
|
After Width: | Height: | Size: 548 B |
|
@ -15,6 +15,14 @@ Make sure to pick a password that is at least 10 characters long and contains a
|
||||||
Your password will also be checked against a list of leaked passwords for extra security.
|
Your password will also be checked against a list of leaked passwords for extra security.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Custom Avatar
|
||||||
|
|
||||||
|
By default, Koel uses [Gravatar](https://gravatar.com) to fetch your avatar based on your email address.
|
||||||
|
By hovering over the avatar and clicking the <InterfaceIcon :src="uploadIcon" /> icon, you can select an image file from your computer, crop it, and set it as your custom avatar.
|
||||||
|
Remember to click Save for the change to take effect.
|
||||||
|
|
||||||
|
To remove your custom avatar and revert to using Gravatar, click the <InterfaceIcon :src="timesIcon" /> icon.
|
||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
At the time of this writing, Koel comes with 17 themes built-in. You can activate a theme simply by clicking on it. The new theme will be applied immediately.
|
At the time of this writing, Koel comes with 17 themes built-in. You can activate a theme simply by clicking on it. The new theme will be applied immediately.
|
||||||
|
@ -38,3 +46,8 @@ These preferences are saved immediately upon change and synced across all of you
|
||||||
## Service Integration Statuses
|
## Service Integration Statuses
|
||||||
|
|
||||||
If your Koel installation is [integrated](../service-integrations) with any external services, such as Last.fm or Spotify, you can see their statuses here along with the ability to connect or disconnect them when applicable.
|
If your Koel installation is [integrated](../service-integrations) with any external services, such as Last.fm or Spotify, you can see their statuses here along with the ability to connect or disconnect them when applicable.
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import uploadIcon from '../assets/icons/upload.svg'
|
||||||
|
import timesIcon from '../assets/icons/times.svg'
|
||||||
|
</script>
|
||||||
|
|
24
package.json
24
package.json
|
@ -37,15 +37,11 @@
|
||||||
"youtube-player": "^3.0.4"
|
"youtube-player": "^3.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.9",
|
|
||||||
"@babel/polyfill": "^7.8.7",
|
|
||||||
"@babel/preset-env": "^7.9.6",
|
|
||||||
"@faker-js/faker": "^6.2.0",
|
"@faker-js/faker": "^6.2.0",
|
||||||
"@floating-ui/dom": "^1.0.3",
|
"@floating-ui/dom": "^1.0.3",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@testing-library/vue": "^6.6.1",
|
"@testing-library/vue": "^6.6.1",
|
||||||
"@types/axios": "^0.14.0",
|
"@types/axios": "^0.14.0",
|
||||||
"@types/blueimp-md5": "^2.7.0",
|
|
||||||
"@types/local-storage": "^1.4.0",
|
"@types/local-storage": "^1.4.0",
|
||||||
"@types/lodash": "^4.14.150",
|
"@types/lodash": "^4.14.150",
|
||||||
"@types/nprogress": "^0.2.0",
|
"@types/nprogress": "^0.2.0",
|
||||||
|
@ -55,10 +51,8 @@
|
||||||
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||||
"@typescript-eslint/parser": "^4.11.1",
|
"@typescript-eslint/parser": "^4.11.1",
|
||||||
"@vitejs/plugin-vue": "^3.1.2",
|
"@vitejs/plugin-vue": "^3.1.2",
|
||||||
"@vue/test-utils": "^2.1.0",
|
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"css-loader": "^0.28.7",
|
|
||||||
"cypress": "^9.5.4",
|
"cypress": "^9.5.4",
|
||||||
"eslint": "^8.14.0",
|
"eslint": "^8.14.0",
|
||||||
"eslint-plugin-import": "^2.20.2",
|
"eslint-plugin-import": "^2.20.2",
|
||||||
|
@ -66,37 +60,32 @@
|
||||||
"eslint-plugin-promise": "^4.2.1",
|
"eslint-plugin-promise": "^4.2.1",
|
||||||
"eslint-plugin-standard": "^4.0.1",
|
"eslint-plugin-standard": "^4.0.1",
|
||||||
"eslint-plugin-vue": "^8.7.1",
|
"eslint-plugin-vue": "^8.7.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
"factoria": "^4.0.0",
|
"factoria": "^4.0.0",
|
||||||
"file-loader": "^1.1.6",
|
|
||||||
"husky": "^4.2.5",
|
"husky": "^4.2.5",
|
||||||
"jest-serializer-vue": "^2.0.2",
|
"jest-serializer-vue": "^2.0.2",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
"jsdom": "^19.0.0",
|
"jsdom": "^19.0.0",
|
||||||
"kill-port": "^1.6.1",
|
"kill-port": "^1.6.1",
|
||||||
"laravel-vite-plugin": "^0.6.1",
|
"laravel-vite-plugin": "^0.6.1",
|
||||||
"lint-staged": "^10.3.0",
|
"lint-staged": "^10.3.0",
|
||||||
"postcss": "^8.4.12",
|
"sass": "^1.72.0",
|
||||||
"resolve-url-loader": "^3.1.1",
|
|
||||||
"sass": "^1.50.0",
|
|
||||||
"sass-loader": "^12.6.0",
|
|
||||||
"start-server-and-test": "^2.0.3",
|
"start-server-and-test": "^2.0.3",
|
||||||
"ts-loader": "^9.3.0",
|
|
||||||
"typescript": "^4.8.4",
|
"typescript": "^4.8.4",
|
||||||
"vite": "^3.1.6",
|
"vite": "^5.1.6",
|
||||||
"vitepress": "^1.0.0-rc.45",
|
"vitepress": "^1.0.0-rc.45",
|
||||||
"vitest": "^0.24.0",
|
"vitest": "^0.24.0",
|
||||||
"vue-loader": "^16.2.0",
|
"vue-advanced-cropper": "^2.8.8"
|
||||||
"webpack": "^5.72.0",
|
|
||||||
"webpack-node-externals": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern",
|
"lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern",
|
||||||
|
"test": "vitest",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'",
|
"test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'",
|
||||||
"test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' http-get://localhost:8080/api/ping 'cypress run --browser chromium'",
|
"test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' http-get://localhost:8080/api/ping 'cypress run --browser chromium'",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build-demo": "cross-env VITE_KOEL_ENV=demo vite build",
|
"build-demo": "cross-env VITE_KOEL_ENV=demo vite build",
|
||||||
"dev": "kill-port 8000 && start-test 'php artisan serve --port=8000 --quiet' 'http://127.0.0.1:8000/api/ping' 'vite'",
|
"dev": "kill-port 8000 && start-test 'php artisan serve --port=8000 --quiet' 'http://127.0.0.1:8000/api/ping' 'vite'",
|
||||||
"prod": "npm run production",
|
|
||||||
"docs:dev": "vitepress dev docs",
|
"docs:dev": "vitepress dev docs",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs"
|
"docs:preview": "vitepress preview docs"
|
||||||
|
@ -117,5 +106,6 @@
|
||||||
"eslint"
|
"eslint"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"type": "module",
|
||||||
"packageManager": "yarn@1.22.19"
|
"packageManager": "yarn@1.22.19"
|
||||||
}
|
}
|
||||||
|
|
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
@ -13,6 +13,8 @@ img/artists/*
|
||||||
!img/artists/.gitkeep
|
!img/artists/.gitkeep
|
||||||
img/playlists/*
|
img/playlists/*
|
||||||
!img/playlists/.gitkeep
|
!img/playlists/.gitkeep
|
||||||
|
img/avatars/*
|
||||||
|
!img/avatars/.gitkeep
|
||||||
|
|
||||||
images
|
images
|
||||||
js
|
js
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="requestResetPasswordLink" data-testid="forgot-password-form">
|
<form data-testid="forgot-password-form" @submit.prevent="requestResetPasswordLink">
|
||||||
<h1 class="font-size-1.5">Forgot Password</h1>
|
<h1 class="font-size-1.5">Forgot Password</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input v-model="email" placeholder="Your email address" required type="email" />
|
<input v-model="email" placeholder="Your email address" required type="email">
|
||||||
<Btn :disabled="loading" type="submit">Reset Password</Btn>
|
<Btn :disabled="loading" type="submit">Reset Password</Btn>
|
||||||
<Btn :disabled="loading" class="text-secondary" transparent @click="cancel">Cancel</Btn>
|
<Btn :disabled="loading" class="text-secondary" transparent @click="cancel">Cancel</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="license-form" @submit.prevent="validateLicenseKey">
|
<form class="license-form" @submit.prevent="validateLicenseKey">
|
||||||
<input
|
<input
|
||||||
|
v-model="licenseKey"
|
||||||
|
v-koel-focus
|
||||||
type="text"
|
type="text"
|
||||||
name="license"
|
name="license"
|
||||||
v-model="licenseKey"
|
|
||||||
placeholder="Enter your license key"
|
placeholder="Enter your license key"
|
||||||
required
|
required
|
||||||
v-koel-focus
|
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
<Btn blue type="submit" :disabled="loading">Activate</Btn>
|
<Btn blue type="submit" :disabled="loading">Activate</Btn>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<a href class="upgrade-to-plus-btn" @click.prevent="openModal">
|
<a href class="upgrade-to-plus-btn" @click.prevent="openModal">
|
||||||
<Icon :icon="faPlus" fixed-width/>
|
<Icon :icon="faPlus" fixed-width />
|
||||||
Upgrade to Plus
|
Upgrade to Plus
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -31,12 +31,12 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faFolder, faFolderOpen } from '@fortawesome/free-solid-svg-icons'
|
import { faFolder, faFolderOpen } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, defineAsyncComponent, ref, toRefs } from 'vue'
|
import { computed, ref, toRefs } from 'vue'
|
||||||
import { playlistFolderStore, playlistStore } from '@/stores'
|
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { useDraggable, useDroppable } from '@/composables'
|
import { useDraggable, useDroppable } from '@/composables'
|
||||||
|
|
||||||
const PlaylistSidebarItem = defineAsyncComponent(() => import('./PlaylistSidebarItem.vue'))
|
import PlaylistSidebarItem from '@/components/layout/main-wrapper/sidebar/PlaylistSidebarItem.vue'
|
||||||
|
|
||||||
const props = defineProps<{ folder: PlaylistFolder }>()
|
const props = defineProps<{ folder: PlaylistFolder }>()
|
||||||
const { folder } = toRefs(props)
|
const { folder } = toRefs(props)
|
||||||
|
|
|
@ -17,9 +17,9 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="upgrade" v-if="isAdmin">
|
<p v-if="isAdmin" class="upgrade">
|
||||||
<!-- close the modal first to prevent it from overlapping Lemonsqueezy's overlay -->
|
<!-- close the modal first to prevent it from overlapping Lemonsqueezy's overlay -->
|
||||||
<BtnUpgradeToPlus @click="close"/>
|
<BtnUpgradeToPlus @click="close" />
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
<li @click="shuffle">Shuffle</li>
|
<li @click="shuffle">Shuffle</li>
|
||||||
<li @click="addToQueue">Add to Queue</li>
|
<li @click="addToQueue">Add to Queue</li>
|
||||||
<template v-if="canShowCollaboration">
|
<template v-if="canShowCollaboration">
|
||||||
<li class="separator"></li>
|
<li class="separator" />
|
||||||
<li @click="showCollaborationModal">Collaborate…</li>
|
<li @click="showCollaborationModal">Collaborate…</li>
|
||||||
<li class="separator"></li>
|
<li class="separator" />
|
||||||
</template>
|
</template>
|
||||||
<li v-if="ownedByCurrentUser" @click="edit">Edit…</li>
|
<li v-if="ownedByCurrentUser" @click="edit">Edit…</li>
|
||||||
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>
|
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row rules" v-koel-overflow-fade>
|
<div v-koel-overflow-fade class="form-row rules">
|
||||||
<RuleGroup
|
<RuleGroup
|
||||||
v-for="(group, index) in collectedRuleGroups"
|
v-for="(group, index) in collectedRuleGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row" v-if="isPlus">
|
<div v-if="isPlus" class="form-row">
|
||||||
<label class="own-songs-only text-secondary small">
|
<label class="own-songs-only text-secondary small">
|
||||||
<CheckBox v-model="ownSongsOnly" /> Only include songs from my own library
|
<CheckBox v-model="ownSongsOnly" /> Only include songs from my own library
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row rules" v-koel-overflow-fade>
|
<div v-koel-overflow-fade class="form-row rules">
|
||||||
<RuleGroup
|
<RuleGroup
|
||||||
v-for="(group, index) in mutablePlaylist.rules"
|
v-for="(group, index) in mutablePlaylist.rules"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
|
@ -39,7 +39,7 @@
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row" v-if="isPlus">
|
<div v-if="isPlus" class="form-row">
|
||||||
<label class="own-songs-only text-secondary small">
|
<label class="own-songs-only text-secondary small">
|
||||||
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only include songs from my own library
|
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only include songs from my own library
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -0,0 +1,168 @@
|
||||||
|
<template>
|
||||||
|
<div class="avatar">
|
||||||
|
<UserAvatar v-if="profile.avatar" :user="profile" style="width: var(--w)" />
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="upload" type="button" title="Pick a new avatar" @click.prevent="openFileDialog">
|
||||||
|
<Icon :icon="faUpload" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="avatarChanged" type="button" class="reset" title="Reset avatar" @click.prevent="resetAvatar">
|
||||||
|
<Icon :icon="faRefresh" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-else class="remove" type="button" title="Remove avatar" @click.prevent="removeAvatar">
|
||||||
|
<Icon :icon="faTimes" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cropperSource" class="cropper-wrapper">
|
||||||
|
<div>
|
||||||
|
<Cropper
|
||||||
|
ref="cropper"
|
||||||
|
:src="cropperSource"
|
||||||
|
:stencil-props="{ aspectRatio: 1 }"
|
||||||
|
:min-height="192"
|
||||||
|
:max-height="480"
|
||||||
|
/>
|
||||||
|
<div class="controls">
|
||||||
|
<Btn type="button" green @click.prevent="crop">Crop</Btn>
|
||||||
|
<Btn type="button" red @click.prevent="cropperSource = null">Cancel</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
faRefresh,
|
||||||
|
faTimes,
|
||||||
|
faUpload
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Cropper } from 'vue-advanced-cropper'
|
||||||
|
import 'vue-advanced-cropper/dist/style.css'
|
||||||
|
import { computed, ref, toRefs } from 'vue'
|
||||||
|
import { useFileDialog } from '@vueuse/core'
|
||||||
|
import { userStore } from '@/stores'
|
||||||
|
import { useFileReader } from '@/composables'
|
||||||
|
import { gravatar } from '@/utils'
|
||||||
|
|
||||||
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ profile: Pick<User, 'name' | 'avatar'> }>()
|
||||||
|
const { profile } = toRefs(props)
|
||||||
|
|
||||||
|
const cropper = ref<typeof Cropper>()
|
||||||
|
|
||||||
|
const { open: openFileDialog, onChange } = useFileDialog({
|
||||||
|
accept: 'image/*',
|
||||||
|
multiple: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropperSource = ref<string | null>(null)
|
||||||
|
|
||||||
|
onChange(files => {
|
||||||
|
if (!files?.length) {
|
||||||
|
profile.value.avatar = userStore.current.avatar
|
||||||
|
cropperSource.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useFileReader().readAsDataUrl(files[0], url => {
|
||||||
|
cropperSource.value = url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeAvatar = () => profile.value.avatar = gravatar(userStore.current.email)
|
||||||
|
|
||||||
|
const resetAvatar = () => {
|
||||||
|
profile.value.avatar = userStore.current.avatar
|
||||||
|
cropperSource.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarChanged = computed(() => profile.value.avatar !== userStore.current.avatar)
|
||||||
|
|
||||||
|
const crop = () => {
|
||||||
|
const { canvas } = cropper.value!.getResult()
|
||||||
|
profile.value.avatar = canvas.toDataURL()
|
||||||
|
cropperSource.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.avatar {
|
||||||
|
--w: 105px;
|
||||||
|
outline: rgba(255, 255, 255, .1) solid 3px;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, .1);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: var(--w);
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding-top: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .3s;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: rgba(0, 0, 0, .3);
|
||||||
|
width: 28px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, .7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 99;
|
||||||
|
background: rgba(0, 0, 0, .5);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.5rem;
|
||||||
|
top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -7,7 +7,7 @@ import { MessageToasterStub } from '@/__tests__/stubs'
|
||||||
import ProfileForm from './ProfileForm.vue'
|
import ProfileForm from './ProfileForm.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private async renderComponent (user: User) {
|
private renderComponent (user: User) {
|
||||||
return this.be(user).render(ProfileForm)
|
return this.be(user).render(ProfileForm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,7 +16,9 @@ new class extends UnitTestCase {
|
||||||
const updateMock = this.mock(authService, 'updateProfile')
|
const updateMock = this.mock(authService, 'updateProfile')
|
||||||
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
||||||
|
|
||||||
await this.renderComponent(factory<User>('user'))
|
this.renderComponent(factory<User>('user', {
|
||||||
|
avatar: 'https://gravatar.com/foo'
|
||||||
|
}))
|
||||||
|
|
||||||
await this.type(screen.getByTestId('currentPassword'), 'old-password')
|
await this.type(screen.getByTestId('currentPassword'), 'old-password')
|
||||||
await this.type(screen.getByTestId('email'), 'koel@example.com')
|
await this.type(screen.getByTestId('email'), 'koel@example.com')
|
||||||
|
@ -29,6 +31,7 @@ new class extends UnitTestCase {
|
||||||
email: 'koel@example.com',
|
email: 'koel@example.com',
|
||||||
current_password: 'old-password',
|
current_password: 'old-password',
|
||||||
new_password: 'new-password',
|
new_password: 'new-password',
|
||||||
|
avatar: 'https://gravatar.com/foo'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Profile updated.')
|
expect(alertMock).toHaveBeenCalledWith('Profile updated.')
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<form data-testid="update-profile-form" @submit.prevent="update">
|
<form data-testid="update-profile-form" @submit.prevent="update">
|
||||||
|
<div class="profile form-row">
|
||||||
|
<div class="left">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
Current Password
|
Current Password
|
||||||
|
@ -25,7 +27,10 @@
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
Email Address
|
Email Address
|
||||||
<input id="inputProfileEmail" v-model="profile.email" name="email" required type="email" data-testid="email">
|
<input
|
||||||
|
id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
|
||||||
|
data-testid="email"
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -44,6 +49,10 @@
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditableProfileAvatar :profile="profile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<Btn class="btn-submit" type="submit">Save</Btn>
|
<Btn class="btn-submit" type="submit">Save</Btn>
|
||||||
|
@ -63,10 +72,12 @@ import { useDialogBox, useMessageToaster } from '@/composables'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||||
|
import EditableProfileAvatar from '@/components/profile-preferences/EditableProfileAvatar.vue'
|
||||||
|
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
const { showErrorDialog } = useDialogBox()
|
const { showErrorDialog } = useDialogBox()
|
||||||
const profile = ref<UpdateCurrentProfileData>({} as unknown as UpdateCurrentProfileData)
|
|
||||||
|
const profile = ref<UpdateCurrentProfileData>({} as UpdateCurrentProfileData)
|
||||||
|
|
||||||
const isDemo = window.IS_DEMO
|
const isDemo = window.IS_DEMO
|
||||||
|
|
||||||
|
@ -74,6 +85,7 @@ onMounted(() => {
|
||||||
profile.value = {
|
profile.value = {
|
||||||
name: userStore.current.name,
|
name: userStore.current.name,
|
||||||
email: userStore.current.email,
|
email: userStore.current.email,
|
||||||
|
avatar: userStore.current.avatar,
|
||||||
current_password: null
|
current_password: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -95,7 +107,7 @@ const update = async () => {
|
||||||
toastSuccess('Profile updated.')
|
toastSuccess('Profile updated.')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const msg = error.response.status === 422 ? parseValidationError(error.response.data)[0] : 'Unknown error.'
|
const msg = error.response.status === 422 ? parseValidationError(error.response.data)[0] : 'Unknown error.'
|
||||||
showErrorDialog(msg, 'Error')
|
await showErrorDialog(msg, 'Error')
|
||||||
logger.log(error)
|
logger.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -103,11 +115,21 @@ const update = async () => {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
form {
|
form {
|
||||||
width: 33%;
|
width: 66%;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2.5rem;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-rules {
|
.password-rules {
|
||||||
|
@ -120,12 +142,4 @@ form {
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
|
||||||
input {
|
|
||||||
&[type="text"], &[type="email"], &[type="password"] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
</span>
|
</span>
|
||||||
</ScreenEmptyState>
|
</ScreenEmptyState>
|
||||||
|
|
||||||
<div class="main-scroll-wrap" v-else>
|
<div v-else class="main-scroll-wrap">
|
||||||
<ul v-if="genres" class="genres">
|
<ul v-if="genres" class="genres">
|
||||||
<li v-for="genre in genres" :key="genre.name" :class="`level-${getLevel(genre)}`">
|
<li v-for="genre in genres" :key="genre.name" :class="`level-${getLevel(genre)}`">
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -6,12 +6,12 @@
|
||||||
|
|
||||||
<template #thumbnail>
|
<template #thumbnail>
|
||||||
<PlaylistThumbnail :playlist="playlist">
|
<PlaylistThumbnail :playlist="playlist">
|
||||||
<ThumbnailStack :thumbnails="thumbnails" v-if="!playlist.cover" />
|
<ThumbnailStack v-if="!playlist.cover" :thumbnails="thumbnails" />
|
||||||
</PlaylistThumbnail>
|
</PlaylistThumbnail>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="songs.length || playlist.is_collaborative" #meta>
|
<template v-if="songs.length || playlist.is_collaborative" #meta>
|
||||||
<CollaboratorsBadge :collaborators="collaborators" v-if="collaborators.length" />
|
<CollaboratorsBadge v-if="collaborators.length" :collaborators="collaborators" />
|
||||||
<span>{{ pluralize(songs, 'song') }}</span>
|
<span>{{ pluralize(songs, 'song') }}</span>
|
||||||
<span>{{ duration }}</span>
|
<span>{{ duration }}</span>
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -68,8 +68,8 @@ import { useUpload } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
|
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||||
|
|
||||||
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue'))
|
|
||||||
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
|
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
|
||||||
const UploadItem = defineAsyncComponent(() => import('@/components/ui/upload/UploadItem.vue'))
|
const UploadItem = defineAsyncComponent(() => import('@/components/ui/upload/UploadItem.vue'))
|
||||||
|
|
||||||
|
|
|
@ -50,9 +50,9 @@ import {useAuthorization} from '@/composables'
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||||
import UserCard from '@/components/user/UserCard.vue'
|
import UserCard from '@/components/user/UserCard.vue'
|
||||||
|
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||||
|
|
||||||
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
|
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
|
||||||
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue'))
|
|
||||||
|
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, toRef, toRefs, watch } from 'vue'
|
import { computed, toRef, toRefs, watch } from 'vue'
|
||||||
import { pluralize } from '@/utils'
|
import { pluralize } from '@/utils'
|
||||||
import { playlistStore, queueStore } from '@/stores'
|
import { playlistStore, queueStore } from '@/stores'
|
||||||
import { useSongMenuMethods } from '@/composables'
|
import { useSongMenuMethods } from '@/composables'
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<main>
|
<main>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="external-mark" v-if="external">
|
<span v-if="external" class="external-mark">
|
||||||
<Icon :icon="faSquareUpRight" />
|
<Icon :icon="faSquareUpRight" />
|
||||||
</span>
|
</span>
|
||||||
{{ song.title }}
|
{{ song.title }}
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
</template>
|
</template>
|
||||||
<li v-if="normalPlaylists.length" class="separator" />
|
<li v-if="normalPlaylists.length" class="separator" />
|
||||||
<template class="d-block">
|
<template class="d-block">
|
||||||
<ul class="playlists" v-koel-overflow-fade v-if="normalPlaylists.length">
|
<ul v-if="normalPlaylists.length" v-koel-overflow-fade class="playlists">
|
||||||
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" class="song-list-controls" data-testid="song-list-controls" v-if="config">
|
<div v-if="config" ref="el" class="song-list-controls" data-testid="song-list-controls">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<BtnGroup uppercased>
|
<BtnGroup uppercased>
|
||||||
<template v-if="altPressed">
|
<template v-if="altPressed">
|
||||||
<Btn
|
<Btn
|
||||||
v-if="selectedSongs.length < 2 && songs.length"
|
v-if="selectedSongs.length < 2 && songs.length"
|
||||||
|
v-koel-tooltip.bottom
|
||||||
class="btn-play-all"
|
class="btn-play-all"
|
||||||
orange
|
orange
|
||||||
title="Play all. Press Alt/⌥ to change mode."
|
title="Play all. Press Alt/⌥ to change mode."
|
||||||
v-koel-tooltip.bottom
|
|
||||||
@click.prevent="playAll"
|
@click.prevent="playAll"
|
||||||
>
|
>
|
||||||
<Icon :icon="faPlay" fixed-width />
|
<Icon :icon="faPlay" fixed-width />
|
||||||
|
@ -17,10 +17,10 @@
|
||||||
|
|
||||||
<Btn
|
<Btn
|
||||||
v-if="selectedSongs.length > 1"
|
v-if="selectedSongs.length > 1"
|
||||||
|
v-koel-tooltip.bottom
|
||||||
class="btn-play-selected"
|
class="btn-play-selected"
|
||||||
orange
|
orange
|
||||||
title="Play selected. Press Alt/⌥ to change mode."
|
title="Play selected. Press Alt/⌥ to change mode."
|
||||||
v-koel-tooltip.bottom
|
|
||||||
@click.prevent="playSelected"
|
@click.prevent="playSelected"
|
||||||
>
|
>
|
||||||
<Icon :icon="faPlay" fixed-width />
|
<Icon :icon="faPlay" fixed-width />
|
||||||
|
@ -31,11 +31,11 @@
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Btn
|
<Btn
|
||||||
v-if="selectedSongs.length < 2 && songs.length"
|
v-if="selectedSongs.length < 2 && songs.length"
|
||||||
|
v-koel-tooltip.bottom
|
||||||
class="btn-shuffle-all"
|
class="btn-shuffle-all"
|
||||||
data-testid="btn-shuffle-all"
|
data-testid="btn-shuffle-all"
|
||||||
orange
|
orange
|
||||||
title="Shuffle all. Press Alt/⌥ to change mode."
|
title="Shuffle all. Press Alt/⌥ to change mode."
|
||||||
v-koel-tooltip.bottom
|
|
||||||
@click.prevent="shuffle"
|
@click.prevent="shuffle"
|
||||||
>
|
>
|
||||||
<Icon :icon="faRandom" fixed-width />
|
<Icon :icon="faRandom" fixed-width />
|
||||||
|
@ -44,11 +44,11 @@
|
||||||
|
|
||||||
<Btn
|
<Btn
|
||||||
v-if="selectedSongs.length > 1"
|
v-if="selectedSongs.length > 1"
|
||||||
|
v-koel-tooltip.bottom
|
||||||
class="btn-shuffle-selected"
|
class="btn-shuffle-selected"
|
||||||
data-testid="btn-shuffle-selected"
|
data-testid="btn-shuffle-selected"
|
||||||
orange
|
orange
|
||||||
title="Shuffle selected. Press Alt/⌥ to change mode."
|
title="Shuffle selected. Press Alt/⌥ to change mode."
|
||||||
v-koel-tooltip.bottom
|
|
||||||
@click.prevent="shuffleSelected"
|
@click.prevent="shuffleSelected"
|
||||||
>
|
>
|
||||||
<Icon :icon="faRandom" fixed-width />
|
<Icon :icon="faRandom" fixed-width />
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span class="title-artist">
|
<span class="title-artist">
|
||||||
<span class="title text-primary">
|
<span class="title text-primary">
|
||||||
<span class="external-mark" v-if="external">
|
<span v-if="external" class="external-mark">
|
||||||
<Icon :icon="faSquareUpRight" />
|
<Icon :icon="faSquareUpRight" />
|
||||||
</span>
|
</span>
|
||||||
{{ song.title }}
|
{{ song.title }}
|
||||||
|
|
|
@ -26,8 +26,8 @@ import { orderBy } from 'lodash'
|
||||||
import { computed, ref, toRef, toRefs } from 'vue'
|
import { computed, ref, toRef, toRefs } from 'vue'
|
||||||
import { albumStore, artistStore, queueStore, songStore, userStore } from '@/stores'
|
import { albumStore, artistStore, queueStore, songStore, userStore } from '@/stores'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { defaultCover, fileReader, logger } from '@/utils'
|
import { defaultCover, logger } from '@/utils'
|
||||||
import { useAuthorization, useMessageToaster, useRouter, useKoelPlus } from '@/composables'
|
import { useAuthorization, useMessageToaster, useRouter, useKoelPlus, useFileReader } from '@/composables'
|
||||||
import { acceptedImageTypes } from '@/config'
|
import { acceptedImageTypes } from '@/config'
|
||||||
|
|
||||||
const { toastSuccess } = useMessageToaster()
|
const { toastSuccess } = useMessageToaster()
|
||||||
|
@ -107,17 +107,17 @@ const onDrop = async (event: DragEvent) => {
|
||||||
const backupImage = forAlbum.value ? (entity.value as Album).cover : (entity.value as Artist).image
|
const backupImage = forAlbum.value ? (entity.value as Album).cover : (entity.value as Artist).image
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
|
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => {
|
||||||
|
|
||||||
if (forAlbum.value) {
|
if (forAlbum.value) {
|
||||||
// Replace the image right away to create an "instant" effect
|
// Replace the image right away to create an "instant" effect
|
||||||
(entity.value as Album).cover = fileData
|
(entity.value as Album).cover = url
|
||||||
await albumStore.uploadCover(entity.value as Album, fileData)
|
await albumStore.uploadCover(entity.value as Album, url)
|
||||||
} else {
|
} else {
|
||||||
(entity.value as Artist).image = fileData
|
(entity.value as Artist).image = url as string
|
||||||
await artistStore.uploadImage(entity.value as Artist, fileData)
|
await artistStore.uploadImage(entity.value as Artist, url)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})
|
||||||
|
} catch (e: any) {
|
||||||
const message = e?.response?.data?.message ?? 'Unknown error.'
|
const message = e?.response?.data?.message ?? 'Unknown error.'
|
||||||
toastError(`Failed to upload: ${message}`)
|
toastError(`Failed to upload: ${message}`)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<span :class="value && 'checked'">
|
<span :class="value && 'checked'">
|
||||||
<input :checked="value" type="checkbox" v-bind="$attrs" v-model="value">
|
<input v-bind="$attrs" v-model="value" :checked="value" type="checkbox">
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -10,16 +10,16 @@
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
>
|
>
|
||||||
<div class="pointer-events-none">
|
<div class="pointer-events-none">
|
||||||
<slot/>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, toRef, toRefs } from 'vue'
|
import { computed, ref, toRef, toRefs } from 'vue'
|
||||||
import { defaultCover, fileReader, logger } from '@/utils'
|
import { defaultCover, logger } from '@/utils'
|
||||||
import { playlistStore, userStore } from '@/stores'
|
import { playlistStore, userStore } from '@/stores'
|
||||||
import { useAuthorization, useKoelPlus, useMessageToaster } from '@/composables'
|
import { useAuthorization, useFileReader, useKoelPlus, useMessageToaster } from '@/composables'
|
||||||
import { acceptedImageTypes } from '@/config'
|
import { acceptedImageTypes } from '@/config'
|
||||||
|
|
||||||
const props = defineProps<{ playlist: Playlist }>()
|
const props = defineProps<{ playlist: Playlist }>()
|
||||||
|
@ -66,11 +66,11 @@ const onDrop = async (event: DragEvent) => {
|
||||||
const backupImage = playlist.value.cover
|
const backupImage = playlist.value.cover
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
|
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => {
|
||||||
|
|
||||||
// Replace the image right away to create an "instant" effect
|
// Replace the image right away to create an "instant" effect
|
||||||
playlist.value.cover = fileData
|
playlist.value.cover = url
|
||||||
await playlistStore.uploadCover(playlist.value, fileData)
|
await playlistStore.uploadCover(playlist.value, url)
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e?.response?.data?.message ?? 'Unknown error.'
|
const message = e?.response?.data?.message ?? 'Unknown error.'
|
||||||
toastError(`Failed to upload: ${message}`)
|
toastError(`Failed to upload: ${message}`)
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
href="/#/profile"
|
href="/#/profile"
|
||||||
title="Profile and preferences"
|
title="Profile and preferences"
|
||||||
>
|
>
|
||||||
<UserAvatar :user="currentUser" width="40"/>
|
<UserAvatar :user="currentUser" width="40" />
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="wrapper skeleton">
|
<div class="wrapper skeleton">
|
||||||
<div v-for="i in 3" :key="i" class="pulse"/>
|
<div v-for="i in 3" :key="i" class="pulse" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -14,5 +14,6 @@ img {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -2,6 +2,7 @@ export * from './useAuthorization'
|
||||||
export * from './useContextMenu'
|
export * from './useContextMenu'
|
||||||
export * from './useDialogBox'
|
export * from './useDialogBox'
|
||||||
export * from './useDragAndDrop'
|
export * from './useDragAndDrop'
|
||||||
|
export * from './useFileReader'
|
||||||
export * from './useFloatingUi'
|
export * from './useFloatingUi'
|
||||||
export * from './useInfiniteScroll'
|
export * from './useInfiniteScroll'
|
||||||
export * from './useKoelPlus'
|
export * from './useKoelPlus'
|
||||||
|
|
12
resources/assets/js/composables/useFileReader.ts
Normal file
12
resources/assets/js/composables/useFileReader.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export const useFileReader = () => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
const readAsDataUrl = (file: File, callback: (result: string) => void | Promise<void>) => {
|
||||||
|
reader.addEventListener('load', async () => await callback(reader.result as string))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
readAsDataUrl
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { md5 as baseMd5 } from 'js-md5'
|
||||||
|
|
||||||
export const uuid = () => {
|
export const uuid = () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -11,6 +13,8 @@ export const uuid = () => {
|
||||||
: URL.createObjectURL(new Blob([])).split(/[:\/]/g).pop()
|
: URL.createObjectURL(new Blob([])).split(/[:\/]/g).pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const md5 = (str: string) => baseMd5(str)
|
||||||
|
|
||||||
export const base64Encode = (str: string) => {
|
export const base64Encode = (str: string) => {
|
||||||
return btoa(String.fromCodePoint(...(new TextEncoder().encode(str))))
|
return btoa(String.fromCodePoint(...(new TextEncoder().encode(str))))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { isObject, without } from 'lodash'
|
import { isObject, without } from 'lodash'
|
||||||
import { inject, InjectionKey, isRef, provide, reactive, readonly, shallowReadonly } from 'vue'
|
import { inject, InjectionKey, isRef, provide, readonly, shallowReadonly } from 'vue'
|
||||||
import { ReadonlyInjectionKey } from '@/symbols'
|
import { ReadonlyInjectionKey } from '@/symbols'
|
||||||
import { logger } from '@/utils'
|
import { logger, md5 } from '@/utils'
|
||||||
|
|
||||||
export const use = <T> (value: T, cb: (arg: T) => void) => {
|
export const use = <T> (value: T, cb: (arg: T) => void) => {
|
||||||
if (typeof value === 'undefined' || value === null) {
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
@ -63,3 +63,8 @@ export const moveItemsInList = <T> (list: T[], items: T | T[], target: T, type:
|
||||||
|
|
||||||
return updatedList
|
return updatedList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const gravatar = (email: string, size = 192) => {
|
||||||
|
const hash = md5(email.trim().toLowerCase())
|
||||||
|
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=robohash`
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Hash;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
use function Tests\create_user;
|
use function Tests\create_user;
|
||||||
|
use function Tests\read_as_data_url;
|
||||||
|
use function Tests\test_path;
|
||||||
|
|
||||||
class ProfileTest extends TestCase
|
class ProfileTest extends TestCase
|
||||||
{
|
{
|
||||||
|
@ -55,4 +57,42 @@ class ProfileTest extends TestCase
|
||||||
self::assertSame('bar@baz.com', $user->email);
|
self::assertSame('bar@baz.com', $user->email);
|
||||||
self::assertTrue(Hash::check('new-secret', $user->password));
|
self::assertTrue(Hash::check('new-secret', $user->password));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testUpdateProfileWithAvatar(): void
|
||||||
|
{
|
||||||
|
$user = create_user(['password' => Hash::make('secret')]);
|
||||||
|
self::assertNull($user->getRawOriginal('avatar'));
|
||||||
|
|
||||||
|
$this->putAs('api/me', [
|
||||||
|
'name' => 'Foo',
|
||||||
|
'email' => 'bar@baz.com',
|
||||||
|
'current_password' => 'secret',
|
||||||
|
'avatar' => read_as_data_url(test_path('blobs/cover.png')),
|
||||||
|
], $user)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
self::assertFileExists(user_avatar_path($user->getRawOriginal('avatar')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateProfileRemovingAvatar(): void
|
||||||
|
{
|
||||||
|
$user = create_user([
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'email' => 'foo@bar.com',
|
||||||
|
'avatar' => 'foo.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->putAs('api/me', [
|
||||||
|
'name' => 'Foo',
|
||||||
|
'email' => 'foo@bar.com',
|
||||||
|
'current_password' => 'secret',
|
||||||
|
], $user)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
self::assertNull($user->getRawOriginal('avatar'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,3 +18,8 @@ function test_path(string $path = ''): string
|
||||||
{
|
{
|
||||||
return base_path('tests' . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR));
|
return base_path('tests' . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function read_as_data_url(string $path): string
|
||||||
|
{
|
||||||
|
return 'data:' . mime_content_type($path) . ';base64,' . base64_encode(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
|
@ -39,11 +39,13 @@ abstract class TestCase extends BaseTestCase
|
||||||
'koel.album_cover_dir' => 'sandbox/img/covers/',
|
'koel.album_cover_dir' => 'sandbox/img/covers/',
|
||||||
'koel.artist_image_dir' => 'sandbox/img/artists/',
|
'koel.artist_image_dir' => 'sandbox/img/artists/',
|
||||||
'koel.playlist_cover_dir' => 'sandbox/img/playlists/',
|
'koel.playlist_cover_dir' => 'sandbox/img/playlists/',
|
||||||
|
'koel.user_avatar_dir' => 'sandbox/img/avatars/',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
|
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
|
||||||
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
|
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
|
||||||
File::ensureDirectoryExists(public_path(config('koel.playlist_cover_dir')));
|
File::ensureDirectoryExists(public_path(config('koel.playlist_cover_dir')));
|
||||||
|
File::ensureDirectoryExists(public_path(config('koel.user_avatar_dir')));
|
||||||
File::ensureDirectoryExists(public_path('sandbox/media/'));
|
File::ensureDirectoryExists(public_path('sandbox/media/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,7 @@ class MediaMetadataServiceTest extends TestCase
|
||||||
|
|
||||||
$this->imageWriter->shouldReceive('write')->once();
|
$this->imageWriter->shouldReceive('write')->once();
|
||||||
|
|
||||||
$this->mediaMetadataService->writeAlbumCover($album, 'dummy-src', 'jpg', $coverPath);
|
$this->mediaMetadataService->writeAlbumCover($album, 'dummy-src', $coverPath);
|
||||||
self::assertSame(album_cover_url('foo.jpg'), $album->refresh()->cover);
|
self::assertSame(album_cover_url('foo.jpg'), $album->refresh()->cover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ class MediaMetadataServiceTest extends TestCase
|
||||||
->once()
|
->once()
|
||||||
->with('/koel/public/img/artist/foo.jpg', 'dummy-src');
|
->with('/koel/public/img/artist/foo.jpg', 'dummy-src');
|
||||||
|
|
||||||
$this->mediaMetadataService->writeArtistImage($artist, 'dummy-src', 'jpg', $imagePath);
|
$this->mediaMetadataService->writeArtistImage($artist, 'dummy-src', $imagePath);
|
||||||
|
|
||||||
self::assertSame(artist_image_url('foo.jpg'), $artist->refresh()->image);
|
self::assertSame(artist_image_url('foo.jpg'), $artist->refresh()->image);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,13 +7,13 @@ import path from 'path'
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
laravel({
|
laravel.default({
|
||||||
input: [
|
input: [
|
||||||
'resources/assets/js/app.ts',
|
'resources/assets/js/app.ts',
|
||||||
'resources/assets/js/remote/app.ts'
|
'resources/assets/js/remote/app.ts'
|
||||||
],
|
],
|
||||||
refresh: true
|
refresh: true
|
||||||
})
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|
Loading…
Reference in a new issue