mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: custom profile avatar
This commit is contained in:
parent
91319d6724
commit
e106bff23d
58 changed files with 597 additions and 2773 deletions
|
@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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())));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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())));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
15
database/migrations/2024_03_19_204549_add_user_avatar.php
Normal file
15
database/migrations/2024_03_19_204549_add_user_avatar.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', static function (Blueprint $table): void {
|
||||
$table->string('avatar')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
1
docs/assets/icons/times.svg
Normal file
1
docs/assets/icons/times.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"></path></svg>
|
After Width: | Height: | Size: 394 B |
1
docs/assets/icons/upload.svg
Normal file
1
docs/assets/icons/upload.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456c13.3 0 24-10.7 24-24s-10.7-24-24-24s-24 10.7-24 24s10.7 24 24 24z"></path></svg>
|
After Width: | Height: | Size: 548 B |
|
@ -15,6 +15,14 @@ Make sure to pick a password that is at least 10 characters long and contains a
|
|||
Your password will also be checked against a list of leaked passwords for extra security.
|
||||
:::
|
||||
|
||||
## 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>
|
||||
|
|
24
package.json
24
package.json
|
@ -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
2
public/.gitignore
vendored
|
@ -13,6 +13,8 @@ img/artists/*
|
|||
!img/artists/.gitkeep
|
||||
img/playlists/*
|
||||
!img/playlists/.gitkeep
|
||||
img/avatars/*
|
||||
!img/avatars/.gitkeep
|
||||
|
||||
images
|
||||
js
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
</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" />
|
||||
</p>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="avatar">
|
||||
<UserAvatar v-if="profile.avatar" :user="profile" style="width: var(--w)" />
|
||||
|
||||
<div class="buttons">
|
||||
<button class="upload" type="button" title="Pick a new avatar" @click.prevent="openFileDialog">
|
||||
<Icon :icon="faUpload" />
|
||||
</button>
|
||||
|
||||
<button v-if="avatarChanged" type="button" class="reset" title="Reset avatar" @click.prevent="resetAvatar">
|
||||
<Icon :icon="faRefresh" />
|
||||
</button>
|
||||
|
||||
<button v-else class="remove" type="button" title="Remove avatar" @click.prevent="removeAvatar">
|
||||
<Icon :icon="faTimes" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="cropperSource" class="cropper-wrapper">
|
||||
<div>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="cropperSource"
|
||||
:stencil-props="{ aspectRatio: 1 }"
|
||||
:min-height="192"
|
||||
:max-height="480"
|
||||
/>
|
||||
<div class="controls">
|
||||
<Btn type="button" green @click.prevent="crop">Crop</Btn>
|
||||
<Btn type="button" red @click.prevent="cropperSource = null">Cancel</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
faRefresh,
|
||||
faTimes,
|
||||
faUpload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { Cropper } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
import { useFileDialog } from '@vueuse/core'
|
||||
import { userStore } from '@/stores'
|
||||
import { useFileReader } from '@/composables'
|
||||
import { gravatar } from '@/utils'
|
||||
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
|
||||
const props = defineProps<{ profile: Pick<User, 'name' | 'avatar'> }>()
|
||||
const { profile } = toRefs(props)
|
||||
|
||||
const cropper = ref<typeof Cropper>()
|
||||
|
||||
const { open: openFileDialog, onChange } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false
|
||||
})
|
||||
|
||||
const cropperSource = ref<string | null>(null)
|
||||
|
||||
onChange(files => {
|
||||
if (!files?.length) {
|
||||
profile.value.avatar = userStore.current.avatar
|
||||
cropperSource.value = null
|
||||
return
|
||||
}
|
||||
|
||||
useFileReader().readAsDataUrl(files[0], url => {
|
||||
cropperSource.value = url
|
||||
})
|
||||
})
|
||||
|
||||
const removeAvatar = () => profile.value.avatar = gravatar(userStore.current.email)
|
||||
|
||||
const resetAvatar = () => {
|
||||
profile.value.avatar = userStore.current.avatar
|
||||
cropperSource.value = null
|
||||
}
|
||||
|
||||
const avatarChanged = computed(() => profile.value.avatar !== userStore.current.avatar)
|
||||
|
||||
const crop = () => {
|
||||
const { canvas } = cropper.value!.getResult()
|
||||
profile.value.avatar = canvas.toDataURL()
|
||||
cropperSource.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.avatar {
|
||||
--w: 105px;
|
||||
outline: rgba(255, 255, 255, .1) solid 3px;
|
||||
margin-top: 2rem;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, .1);
|
||||
aspect-ratio: 1 / 1;
|
||||
width: var(--w);
|
||||
|
||||
.buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
padding-top: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
|
||||
button {
|
||||
background: rgba(0, 0, 0, .3);
|
||||
width: 28px;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
padding: 2px 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, .7);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
top: 1.5rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -7,7 +7,7 @@ import { MessageToasterStub } from '@/__tests__/stubs'
|
|||
import ProfileForm from './ProfileForm.vue'
|
||||
|
||||
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.')
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<form data-testid="update-profile-form" @submit.prevent="update">
|
||||
<div class="profile form-row">
|
||||
<div class="left">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Current Password
|
||||
|
@ -25,7 +27,10 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
Email Address
|
||||
<input id="inputProfileEmail" v-model="profile.email" name="email" required type="email" data-testid="email">
|
||||
<input
|
||||
id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
|
||||
data-testid="email"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
@ -44,6 +49,10 @@
|
|||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditableProfileAvatar :profile="profile" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<Btn class="btn-submit" type="submit">Save</Btn>
|
||||
|
@ -63,10 +72,12 @@ import { useDialogBox, useMessageToaster } from '@/composables'
|
|||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import 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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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])
|
||||
|
||||
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 = fileData
|
||||
await albumStore.uploadCover(entity.value as Album, fileData)
|
||||
(entity.value as Album).cover = url
|
||||
await albumStore.uploadCover(entity.value as Album, url)
|
||||
} else {
|
||||
(entity.value as Artist).image = fileData
|
||||
await artistStore.uploadImage(entity.value as Artist, fileData)
|
||||
(entity.value as Artist).image = url as string
|
||||
await artistStore.uploadImage(entity.value as Artist, url)
|
||||
}
|
||||
} catch (e) {
|
||||
})
|
||||
} catch (e: any) {
|
||||
const message = e?.response?.data?.message ?? 'Unknown error.'
|
||||
toastError(`Failed to upload: ${message}`)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
|
||||
<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])
|
||||
|
||||
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => {
|
||||
// Replace the image right away to create an "instant" effect
|
||||
playlist.value.cover = fileData
|
||||
await playlistStore.uploadCover(playlist.value, fileData)
|
||||
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}`)
|
||||
|
|
|
@ -14,5 +14,6 @@ img {
|
|||
border-radius: 50%;
|
||||
aspect-ratio: 1/1;
|
||||
background: var(--color-bg-primary);
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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'
|
||||
|
|
12
resources/assets/js/composables/useFileReader.ts
Normal file
12
resources/assets/js/composables/useFileReader.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export const useFileReader = () => {
|
||||
const reader = new FileReader()
|
||||
|
||||
const readAsDataUrl = (file: File, callback: (result: string) => void | Promise<void>) => {
|
||||
reader.addEventListener('load', async () => await callback(reader.result as string))
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return {
|
||||
readAsDataUrl
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { md5 as baseMd5 } from 'js-md5'
|
||||
|
||||
export const uuid = () => {
|
||||
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))))
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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/'));
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in a new issue