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_size = 2
[{*.php, *.xml, *.xml.dist}]
[{*.php,*.xml,*.xml.dist}]
indent_size = 4

View file

@ -2,6 +2,7 @@
use Illuminate\Support\Facades\File as FileFacade;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 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;
}
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
{
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
{
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\Models\User;
use App\Services\TokenManager;
use App\Services\UserService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ProfileController extends Controller
@ -17,6 +19,7 @@ class ProfileController extends Controller
/** @param User $user */
public function __construct(
private Hasher $hash,
private UserService $userService,
private TokenManager $tokenManager,
private ?Authenticatable $user
) {
@ -36,15 +39,15 @@ class ProfileController extends Controller
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) {
$data['password'] = $this->hash->make($request->new_password);
}
$this->user->update($data);
$response = UserResource::make($this->user)->response();
$response = UserResource::make($user)->response();
if ($request->new_password) {
$response->header(

View file

@ -9,15 +9,10 @@ use App\Services\MediaMetadataService;
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);
$mediaMetadataService->writeAlbumCover(
$album,
$request->getFileContentAsBinaryString(),
$request->getFileExtension()
);
$metadataService->writeAlbumCover($album, $request->getFileContent());
return response()->json(['cover_url' => $album->cover]);
}

View file

@ -9,18 +9,10 @@ use App\Services\MediaMetadataService;
class UploadArtistImageController extends Controller
{
public function __invoke(
UploadArtistImageRequest $request,
Artist $artist,
MediaMetadataService $mediaMetadataService
) {
public function __invoke(UploadArtistImageRequest $request, Artist $artist, MediaMetadataService $metadataService)
{
$this->authorize('update', $artist);
$mediaMetadataService->writeArtistImage(
$artist,
$request->getFileContentAsBinaryString(),
$request->getFileExtension()
);
$metadataService->writeArtistImage($artist, $request->getFileContent());
return response()->json(['image_url' => $artist->image]);
}

View file

@ -15,12 +15,7 @@ class UploadPlaylistCoverController extends Controller
MediaMetadataService $mediaMetadataService
) {
$this->authorize('collaborate', $playlist);
$mediaMetadataService->writePlaylistCover(
$playlist,
$request->getFileContentAsBinaryString(),
$request->getFileExtension()
);
$mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent());
return response()->json(['cover_url' => $playlist->cover]);
}

View file

@ -43,11 +43,11 @@ class UserController extends Controller
try {
return UserResource::make($this->userService->updateUser(
$user,
$request->name,
$request->email,
$request->password,
$request->get('is_admin') ?: false
user: $user,
name: $request->name,
email: $request->email,
password: $request->password,
isAdmin: $request->get('is_admin') ?: false
));
} catch (UserProspectUpdateDeniedException) {
abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.');

View file

@ -3,7 +3,6 @@
namespace App\Http\Requests\API;
use App\Rules\ImageData;
use Illuminate\Support\Str;
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);
}
public function getFileExtension(): string
{
return Str::after(Str::before($this->{$this->getImageFieldName()}, ';'), '/');
return $this->{$this->getImageFieldName()};
}
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 $new_password
* @property-read string $name
* @property-read string $email
* @property-read string|null $avatar
*/
class ProfileUpdateRequest extends Request
{

View file

@ -76,7 +76,13 @@ class User extends Authenticatable
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

View file

@ -115,10 +115,7 @@ class FileScanner
attempt(function () use ($album, $coverData): void {
// If the album has no cover, we try to get the cover image from existing tag data
if ($coverData) {
$extension = explode('/', $coverData['image_mime']);
$extension = $extension[1] ?? 'png';
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension);
$this->mediaMetadataService->writeAlbumCover($album, $coverData['data']);
return;
}
@ -127,8 +124,7 @@ class FileScanner
$cover = $this->getCoverFileUnderSameDirectory();
if ($cover) {
$extension = pathinfo($cover, PATHINFO_EXTENSION);
$this->mediaMetadataService->writeAlbumCover($album, $cover, $extension);
$this->mediaMetadataService->writeAlbumCover($album, $cover);
}
}, 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|null $destination The destination path. Automatically generated if empty.
*/
public function writeAlbumCover(
Album $album,
string $source,
string $extension = 'png',
?string $destination = '',
bool $cleanUp = true
): void {
attempt(function () use ($album, $source, $extension, $destination, $cleanUp): void {
$extension = trim(strtolower($extension), '. ');
$destination = $destination ?: $this->generateAlbumCoverPath($extension);
public function writeAlbumCover(Album $album, string $source, ?string $destination = '', bool $cleanUp = true): void
{
attempt(function () use ($album, $source, $destination, $cleanUp): void {
$destination = $destination ?: $this->generateAlbumCoverPath();
$this->imageWriter->write($destination, $source);
if ($cleanUp) {
@ -64,13 +58,11 @@ class MediaMetadataService
public function writeArtistImage(
Artist $artist,
string $source,
string $extension = 'png',
?string $destination = '',
bool $cleanUp = true
): void {
attempt(function () use ($artist, $source, $extension, $destination, $cleanUp): void {
$extension = trim(strtolower($extension), '. ');
$destination = $destination ?: $this->generateArtistImagePath($extension);
attempt(function () use ($artist, $source, $destination, $cleanUp): void {
$destination = $destination ?: $this->generateArtistImagePath();
$this->imageWriter->write($destination, $source);
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 {
$extension = trim(strtolower($extension), '. ');
$destination = $this->generatePlaylistCoverPath($extension);
attempt(function () use ($playlist, $source): void {
$destination = $this->generatePlaylistCoverPath();
$this->imageWriter->write($destination, $source);
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);
if ($cover) {
$this->mediaMetadataService->writeAlbumCover(
$album,
base64_decode($cover['data'], true),
$cover['extension']
);
$this->mediaMetadataService->writeAlbumCover($album, base64_decode($cover['data'], true));
}
/** @var Song $song */

View file

@ -5,10 +5,12 @@ namespace App\Services;
use App\Exceptions\UserProspectUpdateDeniedException;
use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
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());
$data = [
'name' => $name,
'email' => $email,
'is_admin' => $isAdmin,
];
if ($isAdmin !== null) {
$data['is_admin'] = $isAdmin;
}
if ($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);
return $user;
@ -52,4 +77,9 @@ class UserService
$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.
'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

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

@ -10,11 +10,19 @@ After that, you can update your name and email, and set a new password.
Leaving the New Password field blank will keep your current password intact.
:::tip Pick a strong password
Koel enforces a strong password policy.
Koel enforces a strong password policy.
Make sure to pick a password that is at least 10 characters long and contains a mix of letters, numbers, and special characters.
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
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
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"
},
"devDependencies": {
"@babel/core": "^7.17.9",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.9.6",
"@faker-js/faker": "^6.2.0",
"@floating-ui/dom": "^1.0.3",
"@testing-library/user-event": "^14.4.3",
"@testing-library/vue": "^6.6.1",
"@types/axios": "^0.14.0",
"@types/blueimp-md5": "^2.7.0",
"@types/local-storage": "^1.4.0",
"@types/lodash": "^4.14.150",
"@types/nprogress": "^0.2.0",
@ -55,10 +51,8 @@
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^4.11.1",
"@vitejs/plugin-vue": "^3.1.2",
"@vue/test-utils": "^2.1.0",
"@vueuse/core": "^10.9.0",
"cross-env": "^7.0.3",
"css-loader": "^0.28.7",
"cypress": "^9.5.4",
"eslint": "^8.14.0",
"eslint-plugin-import": "^2.20.2",
@ -66,37 +60,32 @@
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"eslint-plugin-vue": "^8.7.1",
"events": "^3.3.0",
"factoria": "^4.0.0",
"file-loader": "^1.1.6",
"husky": "^4.2.5",
"jest-serializer-vue": "^2.0.2",
"js-md5": "^0.8.3",
"jsdom": "^19.0.0",
"kill-port": "^1.6.1",
"laravel-vite-plugin": "^0.6.1",
"lint-staged": "^10.3.0",
"postcss": "^8.4.12",
"resolve-url-loader": "^3.1.1",
"sass": "^1.50.0",
"sass-loader": "^12.6.0",
"sass": "^1.72.0",
"start-server-and-test": "^2.0.3",
"ts-loader": "^9.3.0",
"typescript": "^4.8.4",
"vite": "^3.1.6",
"vite": "^5.1.6",
"vitepress": "^1.0.0-rc.45",
"vitest": "^0.24.0",
"vue-loader": "^16.2.0",
"webpack": "^5.72.0",
"webpack-node-externals": "^3.0.0"
"vue-advanced-cropper": "^2.8.8"
},
"scripts": {
"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: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'",
"build": "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'",
"prod": "npm run production",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
@ -117,5 +106,6 @@
"eslint"
]
},
"type": "module",
"packageManager": "yarn@1.22.19"
}

2
public/.gitignore vendored
View file

@ -13,6 +13,8 @@ img/artists/*
!img/artists/.gitkeep
img/playlists/*
!img/playlists/.gitkeep
img/avatars/*
!img/avatars/.gitkeep
images
js

View file

@ -1,9 +1,9 @@
<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>
<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" class="text-secondary" transparent @click="cancel">Cancel</Btn>
</div>

View file

@ -1,17 +1,16 @@
<template>
<form class="license-form" @submit.prevent="validateLicenseKey">
<input
v-model="licenseKey"
v-koel-focus
type="text"
name="license"
v-model="licenseKey"
placeholder="Enter your license key"
required
v-koel-focus
:disabled="loading"
>
<Btn blue type="submit" :disabled="loading">Activate</Btn>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'

View file

@ -1,6 +1,6 @@
<template>
<a href class="upgrade-to-plus-btn" @click.prevent="openModal">
<Icon :icon="faPlus" fixed-width/>
<Icon :icon="faPlus" fixed-width />
Upgrade to Plus
</a>
</template>

View file

@ -31,12 +31,12 @@
<script lang="ts" setup>
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 { eventBus } from '@/utils'
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 { folder } = toRefs(props)

View file

@ -17,9 +17,9 @@
</p>
<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 -->
<BtnUpgradeToPlus @click="close"/>
<BtnUpgradeToPlus @click="close" />
</p>
</template>
</div>

View file

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

View file

@ -4,9 +4,9 @@
<li @click="shuffle">Shuffle</li>
<li @click="addToQueue">Add to Queue</li>
<template v-if="canShowCollaboration">
<li class="separator"></li>
<li class="separator" />
<li @click="showCollaborationModal">Collaborate</li>
<li class="separator"></li>
<li class="separator" />
</template>
<li v-if="ownedByCurrentUser" @click="edit">Edit</li>
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>

View file

@ -20,7 +20,7 @@
</label>
</div>
<div class="form-row rules" v-koel-overflow-fade>
<div v-koel-overflow-fade class="form-row rules">
<RuleGroup
v-for="(group, index) in collectedRuleGroups"
:key="group.id"
@ -34,7 +34,7 @@
</Btn>
</div>
<div class="form-row" v-if="isPlus">
<div v-if="isPlus" class="form-row">
<label class="own-songs-only text-secondary small">
<CheckBox v-model="ownSongsOnly" /> Only include songs from my own library
</label>

View file

@ -26,7 +26,7 @@
</label>
</div>
<div class="form-row rules" v-koel-overflow-fade>
<div v-koel-overflow-fade class="form-row rules">
<RuleGroup
v-for="(group, index) in mutablePlaylist.rules"
:key="group.id"
@ -39,7 +39,7 @@
</Btn>
</div>
<div class="form-row" v-if="isPlus">
<div v-if="isPlus" class="form-row">
<label class="own-songs-only text-secondary small">
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only include songs from my own library
</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'
new class extends UnitTestCase {
private async renderComponent (user: User) {
private renderComponent (user: User) {
return this.be(user).render(ProfileForm)
}
@ -16,7 +16,9 @@ new class extends UnitTestCase {
const updateMock = this.mock(authService, 'updateProfile')
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('email'), 'koel@example.com')
@ -29,6 +31,7 @@ new class extends UnitTestCase {
email: 'koel@example.com',
current_password: 'old-password',
new_password: 'new-password',
avatar: 'https://gravatar.com/foo'
})
expect(alertMock).toHaveBeenCalledWith('Profile updated.')

View file

@ -1,48 +1,57 @@
<template>
<form data-testid="update-profile-form" @submit.prevent="update">
<div class="form-row">
<label>
Current Password
<input
v-model="profile.current_password"
v-koel-focus
name="current_password"
placeholder="Required to update your profile"
required
type="password"
data-testid="currentPassword"
>
</label>
</div>
<div class="profile form-row">
<div class="left">
<div class="form-row">
<label>
Current Password
<input
v-model="profile.current_password"
v-koel-focus
name="current_password"
placeholder="Required to update your profile"
required
type="password"
data-testid="currentPassword"
>
</label>
</div>
<div class="form-row">
<label>
Name
<input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name">
</label>
</div>
<div class="form-row">
<label>
Name
<input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name">
</label>
</div>
<div class="form-row">
<label>
Email Address
<input id="inputProfileEmail" v-model="profile.email" name="email" required type="email" data-testid="email">
</label>
</div>
<div class="form-row">
<label>
Email Address
<input
id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
data-testid="email"
>
</label>
</div>
<div class="form-row">
<label>
New Password
<PasswordField
v-model="profile.new_password"
autocomplete="new-password"
data-testid="newPassword"
minlength="10"
placeholder="Leave empty to keep current password"
/>
<span class="password-rules help">
Min. 10 characters. Should be a mix of characters, numbers, and symbols.
</span>
</label>
<div class="form-row">
<label>
New Password
<PasswordField
v-model="profile.new_password"
autocomplete="new-password"
data-testid="newPassword"
minlength="10"
placeholder="Leave empty to keep current password"
/>
<span class="password-rules help">
Min. 10 characters. Should be a mix of characters, numbers, and symbols.
</span>
</label>
</div>
</div>
<EditableProfileAvatar :profile="profile" />
</div>
<div class="form-row">
@ -63,10 +72,12 @@ import { useDialogBox, useMessageToaster } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
import EditableProfileAvatar from '@/components/profile-preferences/EditableProfileAvatar.vue'
const { toastSuccess } = useMessageToaster()
const { showErrorDialog } = useDialogBox()
const profile = ref<UpdateCurrentProfileData>({} as unknown as UpdateCurrentProfileData)
const profile = ref<UpdateCurrentProfileData>({} as UpdateCurrentProfileData)
const isDemo = window.IS_DEMO
@ -74,6 +85,7 @@ onMounted(() => {
profile.value = {
name: userStore.current.name,
email: userStore.current.email,
avatar: userStore.current.avatar,
current_password: null
}
})
@ -95,7 +107,7 @@ const update = async () => {
toastSuccess('Profile updated.')
} catch (error: any) {
const msg = error.response.status === 422 ? parseValidationError(error.response.data)[0] : 'Unknown error.'
showErrorDialog(msg, 'Error')
await showErrorDialog(msg, 'Error')
logger.log(error)
}
}
@ -103,11 +115,21 @@ const update = async () => {
<style lang="scss" scoped>
form {
width: 33%;
width: 66%;
input {
width: 100%;
}
.profile {
display: flex;
align-items: flex-start;
gap: 2.5rem;
.left {
width: 50%;
}
}
}
.password-rules {
@ -120,12 +142,4 @@ form {
opacity: .7;
margin-left: 5px;
}
@media only screen and (max-width: 667px) {
input {
&[type="text"], &[type="email"], &[type="password"] {
width: 100%;
}
}
}
</style>

View file

@ -12,7 +12,7 @@
</span>
</ScreenEmptyState>
<div class="main-scroll-wrap" v-else>
<div v-else class="main-scroll-wrap">
<ul v-if="genres" class="genres">
<li v-for="genre in genres" :key="genre.name" :class="`level-${getLevel(genre)}`">
<a

View file

@ -6,12 +6,12 @@
<template #thumbnail>
<PlaylistThumbnail :playlist="playlist">
<ThumbnailStack :thumbnails="thumbnails" v-if="!playlist.cover" />
<ThumbnailStack v-if="!playlist.cover" :thumbnails="thumbnails" />
</PlaylistThumbnail>
</template>
<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>{{ duration }}</span>
<a

View file

@ -68,8 +68,8 @@ import { useUpload } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.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 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 ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import UserCard from '@/components/user/UserCard.vue'
import BtnGroup from '@/components/ui/BtnGroup.vue'
const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue'))
const { currentUser } = useAuthorization()

View file

@ -51,7 +51,7 @@
</template>
<script lang="ts" setup>
import { computed, ref, toRef, toRefs, watch } from 'vue'
import { computed, toRef, toRefs, watch } from 'vue'
import { pluralize } from '@/utils'
import { playlistStore, queueStore } from '@/stores'
import { useSongMenuMethods } from '@/composables'

View file

@ -11,7 +11,7 @@
<main>
<div class="details">
<h3>
<span class="external-mark" v-if="external">
<span v-if="external" class="external-mark">
<Icon :icon="faSquareUpRight" />
</span>
{{ song.title }}

View file

@ -23,7 +23,7 @@
</template>
<li v-if="normalPlaylists.length" class="separator" />
<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>
</ul>
</template>

View file

@ -1,14 +1,14 @@
<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">
<BtnGroup uppercased>
<template v-if="altPressed">
<Btn
v-if="selectedSongs.length < 2 && songs.length"
v-koel-tooltip.bottom
class="btn-play-all"
orange
title="Play all. Press Alt/⌥ to change mode."
v-koel-tooltip.bottom
@click.prevent="playAll"
>
<Icon :icon="faPlay" fixed-width />
@ -17,10 +17,10 @@
<Btn
v-if="selectedSongs.length > 1"
v-koel-tooltip.bottom
class="btn-play-selected"
orange
title="Play selected. Press Alt/⌥ to change mode."
v-koel-tooltip.bottom
@click.prevent="playSelected"
>
<Icon :icon="faPlay" fixed-width />
@ -31,11 +31,11 @@
<template v-else>
<Btn
v-if="selectedSongs.length < 2 && songs.length"
v-koel-tooltip.bottom
class="btn-shuffle-all"
data-testid="btn-shuffle-all"
orange
title="Shuffle all. Press Alt/⌥ to change mode."
v-koel-tooltip.bottom
@click.prevent="shuffle"
>
<Icon :icon="faRandom" fixed-width />
@ -44,11 +44,11 @@
<Btn
v-if="selectedSongs.length > 1"
v-koel-tooltip.bottom
class="btn-shuffle-selected"
data-testid="btn-shuffle-selected"
orange
title="Shuffle selected. Press Alt/⌥ to change mode."
v-koel-tooltip.bottom
@click.prevent="shuffleSelected"
>
<Icon :icon="faRandom" fixed-width />

View file

@ -15,7 +15,7 @@
</span>
<span class="title-artist">
<span class="title text-primary">
<span class="external-mark" v-if="external">
<span v-if="external" class="external-mark">
<Icon :icon="faSquareUpRight" />
</span>
{{ song.title }}

View file

@ -26,8 +26,8 @@ import { orderBy } from 'lodash'
import { computed, ref, toRef, toRefs } from 'vue'
import { albumStore, artistStore, queueStore, songStore, userStore } from '@/stores'
import { playbackService } from '@/services'
import { defaultCover, fileReader, logger } from '@/utils'
import { useAuthorization, useMessageToaster, useRouter, useKoelPlus } from '@/composables'
import { defaultCover, logger } from '@/utils'
import { useAuthorization, useMessageToaster, useRouter, useKoelPlus, useFileReader } from '@/composables'
import { acceptedImageTypes } from '@/config'
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
try {
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
if (forAlbum.value) {
// Replace the image right away to create an "instant" effect
(entity.value as Album).cover = fileData
await albumStore.uploadCover(entity.value as Album, fileData)
} else {
(entity.value as Artist).image = fileData
await artistStore.uploadImage(entity.value as Artist, fileData)
}
} catch (e) {
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => {
if (forAlbum.value) {
// Replace the image right away to create an "instant" effect
(entity.value as Album).cover = url
await albumStore.uploadCover(entity.value as Album, url)
} else {
(entity.value as Artist).image = url as string
await artistStore.uploadImage(entity.value as Artist, url)
}
})
} catch (e: any) {
const message = e?.response?.data?.message ?? 'Unknown error.'
toastError(`Failed to upload: ${message}`)

View file

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

View file

@ -10,16 +10,16 @@
@dragover.prevent
>
<div class="pointer-events-none">
<slot/>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toRef, toRefs } from 'vue'
import { defaultCover, fileReader, logger } from '@/utils'
import { defaultCover, logger } from '@/utils'
import { playlistStore, userStore } from '@/stores'
import { useAuthorization, useKoelPlus, useMessageToaster } from '@/composables'
import { useAuthorization, useFileReader, useKoelPlus, useMessageToaster } from '@/composables'
import { acceptedImageTypes } from '@/config'
const props = defineProps<{ playlist: Playlist }>()
@ -66,11 +66,11 @@ const onDrop = async (event: DragEvent) => {
const backupImage = playlist.value.cover
try {
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
// Replace the image right away to create an "instant" effect
playlist.value.cover = fileData
await playlistStore.uploadCover(playlist.value, fileData)
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => {
// Replace the image right away to create an "instant" effect
playlist.value.cover = url
await playlistStore.uploadCover(playlist.value, url)
})
} catch (e) {
const message = e?.response?.data?.message ?? 'Unknown error.'
toastError(`Failed to upload: ${message}`)

View file

@ -7,7 +7,7 @@
href="/#/profile"
title="Profile and preferences"
>
<UserAvatar :user="currentUser" width="40"/>
<UserAvatar :user="currentUser" width="40" />
</a>
</template>

View file

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

View file

@ -14,5 +14,6 @@ img {
border-radius: 50%;
aspect-ratio: 1/1;
background: var(--color-bg-primary);
object-fit: cover;
}
</style>

View file

@ -2,6 +2,7 @@ export * from './useAuthorization'
export * from './useContextMenu'
export * from './useDialogBox'
export * from './useDragAndDrop'
export * from './useFileReader'
export * from './useFloatingUi'
export * from './useInfiniteScroll'
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 = () => {
if (typeof window === 'undefined') {
// @ts-ignore
@ -11,6 +13,8 @@ export const uuid = () => {
: URL.createObjectURL(new Blob([])).split(/[:\/]/g).pop()
}
export const md5 = (str: string) => baseMd5(str)
export const base64Encode = (str: string) => {
return btoa(String.fromCodePoint(...(new TextEncoder().encode(str))))
}

View file

@ -1,7 +1,7 @@
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 { logger } from '@/utils'
import { logger, md5 } from '@/utils'
export const use = <T> (value: T, cb: (arg: T) => void) => {
if (typeof value === 'undefined' || value === null) {
@ -63,3 +63,8 @@ export const moveItemsInList = <T> (list: T[], items: T | T[], target: T, type:
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 function Tests\create_user;
use function Tests\read_as_data_url;
use function Tests\test_path;
class ProfileTest extends TestCase
{
@ -20,7 +22,7 @@ class ProfileTest extends TestCase
public function testUpdateProfileWithoutNewPassword(): void
{
$user = create_user(['password' => Hash::make('secret')]);
$user = create_user(['password' => Hash::make('secret')]);
$this->putAs('api/me', [
'name' => 'Foo',
@ -37,7 +39,7 @@ class ProfileTest extends TestCase
public function testUpdateProfileWithNewPassword(): void
{
$user = create_user(['password' => Hash::make('secret')]);
$user = create_user(['password' => Hash::make('secret')]);
$token = $this->putAs('api/me', [
'name' => 'Foo',
@ -55,4 +57,42 @@ class ProfileTest extends TestCase
self::assertSame('bar@baz.com', $user->email);
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));
}
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.artist_image_dir' => 'sandbox/img/artists/',
'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.artist_image_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/'));
}

View file

@ -56,7 +56,7 @@ class MediaMetadataServiceTest extends TestCase
$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);
}
@ -86,7 +86,7 @@ class MediaMetadataServiceTest extends TestCase
->once()
->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);
}

View file

@ -7,13 +7,13 @@ import path from 'path'
export default defineConfig({
plugins: [
vue(),
laravel({
laravel.default({
input: [
'resources/assets/js/app.ts',
'resources/assets/js/remote/app.ts'
],
refresh: true
})
}),
],
resolve: {
alias: {

2625
yarn.lock

File diff suppressed because it is too large Load diff