feat: custom profile avatar

This commit is contained in:
Phan An 2024-03-19 23:48:12 +01:00
parent 91319d6724
commit e106bff23d
58 changed files with 597 additions and 2773 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
});
}
};

View 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

View 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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<template> <template>
<span> <span>
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn> <Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
<span v-if="justCreatedInviteLink" class="text-secondary copied"> <span v-if="justCreatedInviteLink" class="text-secondary copied">
<Icon :icon="faCheckCircle" class="text-green" /> <Icon :icon="faCheckCircle" class="text-green" />
Link copied to clipboard! Link copied to clipboard!
</span>
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
</span> </span>
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
</span>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,48 +1,57 @@
<template> <template>
<form data-testid="update-profile-form" @submit.prevent="update"> <form data-testid="update-profile-form" @submit.prevent="update">
<div class="form-row"> <div class="profile form-row">
<label> <div class="left">
Current Password <div class="form-row">
<input <label>
v-model="profile.current_password" Current Password
v-koel-focus <input
name="current_password" v-model="profile.current_password"
placeholder="Required to update your profile" v-koel-focus
required name="current_password"
type="password" placeholder="Required to update your profile"
data-testid="currentPassword" required
> type="password"
</label> data-testid="currentPassword"
</div> >
</label>
</div>
<div class="form-row"> <div class="form-row">
<label> <label>
Name Name
<input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name"> <input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name">
</label> </label>
</div> </div>
<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
</label> id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
</div> data-testid="email"
>
</label>
</div>
<div class="form-row"> <div class="form-row">
<label> <label>
New Password New Password
<PasswordField <PasswordField
v-model="profile.new_password" v-model="profile.new_password"
autocomplete="new-password" autocomplete="new-password"
data-testid="newPassword" data-testid="newPassword"
minlength="10" minlength="10"
placeholder="Leave empty to keep current password" placeholder="Leave empty to keep current password"
/> />
<span class="password-rules help"> <span class="password-rules help">
Min. 10 characters. Should be a mix of characters, numbers, and symbols. Min. 10 characters. Should be a mix of characters, numbers, and symbols.
</span> </span>
</label> </label>
</div>
</div>
<EditableProfileAvatar :profile="profile" />
</div> </div>
<div class="form-row"> <div class="form-row">
@ -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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = url
(entity.value as Album).cover = fileData await albumStore.uploadCover(entity.value as Album, url)
await albumStore.uploadCover(entity.value as Album, fileData) } else {
} else { (entity.value as Artist).image = url as string
(entity.value as Artist).image = fileData await artistStore.uploadImage(entity.value as Artist, url)
await artistStore.uploadImage(entity.value as Artist, fileData) }
} })
} 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}`)

View file

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

View file

@ -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 = url
playlist.value.cover = fileData await playlistStore.uploadCover(playlist.value, url)
await playlistStore.uploadCover(playlist.value, fileData) })
} 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}`)

View file

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

View file

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

View file

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

View file

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

View 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
}
}

View file

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

View file

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

View file

@ -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
{ {
@ -20,7 +22,7 @@ class ProfileTest extends TestCase
public function testUpdateProfileWithoutNewPassword(): void public function testUpdateProfileWithoutNewPassword(): void
{ {
$user = create_user(['password' => Hash::make('secret')]); $user = create_user(['password' => Hash::make('secret')]);
$this->putAs('api/me', [ $this->putAs('api/me', [
'name' => 'Foo', 'name' => 'Foo',
@ -37,7 +39,7 @@ class ProfileTest extends TestCase
public function testUpdateProfileWithNewPassword(): void public function testUpdateProfileWithNewPassword(): void
{ {
$user = create_user(['password' => Hash::make('secret')]); $user = create_user(['password' => Hash::make('secret')]);
$token = $this->putAs('api/me', [ $token = $this->putAs('api/me', [
'name' => 'Foo', 'name' => 'Foo',
@ -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'));
}
} }

View file

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

View file

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

View file

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

View file

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

2625
yarn.lock

File diff suppressed because it is too large Load diff