From 1ddfe70c68edc75c1826a8feb78d0bcbc499e689 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Tue, 25 Jun 2024 14:56:16 +0000 Subject: [PATCH] TIC Avatar Modernisierung Closes #4055 Merge request studip/studip!2877 --- app/controllers/course/avatar.php | 17 + app/controllers/course/basicdata.php | 5 +- app/controllers/course/studygroup.php | 16 +- app/controllers/institute/avatar.php | 44 ++ app/controllers/institute/basicdata.php | 2 - app/controllers/settings/avatar.php | 23 + app/views/avatar/update.php | 75 --- app/views/course/avatar/index.php | 7 + app/views/course/studygroup/avatar.php | 7 + app/views/institute/avatar/index.php | 7 + app/views/institute/basicdata/index.php | 20 - app/views/profile/widget-avatar.php | 10 +- app/views/settings/avatar/index.php | 7 + lib/classes/Avatar.php | 5 + lib/classes/JsonApi/RouteMap.php | 9 + .../JsonApi/Routes/Avatar/Authority.php | 29 ++ .../JsonApi/Routes/Avatar/AvatarHelpers.php | 40 ++ .../Routes/Avatar/AvatarOfRangeShow.php | 34 ++ .../JsonApi/Routes/Avatar/AvatarUpload.php | 66 +++ .../Routes/Avatar/AvatarofRangeDelete.php | 36 ++ lib/classes/JsonApi/SchemaMap.php | 2 + lib/classes/JsonApi/Schemas/Avatar.php | 69 +++ lib/modules/CoreAdmin.php | 3 +- lib/modules/CoreStudygroupAdmin.php | 2 +- lib/navigation/AdminNavigation.php | 1 + lib/navigation/ProfileNavigation.php | 1 + public/assets/images/icons/blue/flip.svg | 44 ++ .../assets/javascripts/bootstrap/avatar.js | 61 +-- resources/assets/javascripts/chunk-loader.js | 28 +- resources/assets/javascripts/chunks/avatar.js | 0 resources/vue/avatar-app.js | 76 ++++ resources/vue/components/avatar/AvatarApp.vue | 430 ++++++++++++++++++ resources/vue/store/avatar.module.js | 102 +++++ 33 files changed, 1100 insertions(+), 178 deletions(-) create mode 100644 app/controllers/course/avatar.php create mode 100644 app/controllers/institute/avatar.php create mode 100644 app/controllers/settings/avatar.php delete mode 100644 app/views/avatar/update.php create mode 100644 app/views/course/avatar/index.php create mode 100644 app/views/course/studygroup/avatar.php create mode 100644 app/views/institute/avatar/index.php create mode 100644 app/views/settings/avatar/index.php create mode 100644 lib/classes/JsonApi/Routes/Avatar/Authority.php create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php create mode 100644 lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php create mode 100644 lib/classes/JsonApi/Schemas/Avatar.php create mode 100644 public/assets/images/icons/blue/flip.svg create mode 100644 resources/assets/javascripts/chunks/avatar.js create mode 100644 resources/vue/avatar-app.js create mode 100644 resources/vue/components/avatar/AvatarApp.vue create mode 100644 resources/vue/store/avatar.module.js diff --git a/app/controllers/course/avatar.php b/app/controllers/course/avatar.php new file mode 100644 index 00000000000..b5b34d2c1dd --- /dev/null +++ b/app/controllers/course/avatar.php @@ -0,0 +1,17 @@ +<?php + +class Course_AvatarController extends AuthenticatedController +{ + public function index_action() + { + $this->course_id = Context::getId(); + if (!$GLOBALS['perm']->have_studip_perm('tutor', $this->course_id)) { + throw new AccessDeniedException(_("Sie haben keine Berechtigung diese " . + "Veranstaltung zu verändern.")); + } + PageLayout::setTitle(Context::getHeaderLine() . ' - ' . _('Veranstaltungsbild ändern')); + Navigation::activateItem('/course/admin/avatar'); + $avatar = CourseAvatar::getAvatar($this->course_id); + $this->avatar_url = $avatar->getURL(Avatar::NORMAL); + } +} \ No newline at end of file diff --git a/app/controllers/course/basicdata.php b/app/controllers/course/basicdata.php index 97ec0531cbb..329554f244f 100644 --- a/app/controllers/course/basicdata.php +++ b/app/controllers/course/basicdata.php @@ -379,10 +379,7 @@ class Course_BasicdataController extends AuthenticatedController ); } } - $widget->addLink(_('Bild ändern'), - $this->url_for('avatar/update/course', $this->course_id), - Icon::create('edit') - ); + if ($GLOBALS['perm']->have_perm('admin')) { $is_locked = $course->lock_rule; $widget->addLink( diff --git a/app/controllers/course/studygroup.php b/app/controllers/course/studygroup.php index cd08ba3ce3d..1f9c4a48405 100644 --- a/app/controllers/course/studygroup.php +++ b/app/controllers/course/studygroup.php @@ -383,13 +383,7 @@ class Course_StudygroupController extends AuthenticatedController $this->url_for('course/wizard?studygroup=1'), Icon::create('add') ); - if ($GLOBALS['perm']->have_studip_perm('tutor', $id)) { - $actions->addLink( - _('Bild ändern'), - $this->url_for('avatar/update/course/' . $id), - Icon::create('edit') - ); - } + $actions->addLink( _('Diese Studiengruppe löschen'), $this->deleteURL(), @@ -977,6 +971,12 @@ class Course_StudygroupController extends AuthenticatedController $this->redirect($this->url_for('messages/write', ['course_id' => $id, 'default_subject' => $subject, 'filter' => 'all', 'emailrequest' => 1])); } - + public function avatar_action() + { + Navigation::activateItem('/course/admin/avatar'); + $this->studygroup_id = Context::getId(); + $avatar = StudygroupAvatar::getAvatar($this->studygroup_id); + $this->avatar_url = $avatar->getURL(Avatar::NORMAL); + } } diff --git a/app/controllers/institute/avatar.php b/app/controllers/institute/avatar.php new file mode 100644 index 00000000000..24b840059e3 --- /dev/null +++ b/app/controllers/institute/avatar.php @@ -0,0 +1,44 @@ +<?php + +class Institute_AvatarController extends AuthenticatedController +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + // Ensure only admins gain access to this page + if (!$GLOBALS['perm']->have_perm("admin")) { + throw new AccessDeniedException(); + } + } + public function index_action($i_id = false) + { + //get ID from an open Institut + $i_view = $i_id ?: Request::option('i_view', Context::getId()); + + if (!$i_view) { + Navigation::activateItem('/admin/institute/avatar'); + require_once 'lib/admin_search.inc.php'; + + // This search just died a little inside, so it should be safe to + // continue here but we nevertheless return just to be sure + return; + } elseif ($i_view === 'new') { + closeObject(); + Navigation::activateItem('/admin/institute/create'); + } else { + Navigation::activateItem('/admin/institute/avatar'); + } + + // allow only inst-admin and root to view / edit + if ($i_view && !$GLOBALS['perm']->have_studip_perm('admin', $i_view) && $i_view !== 'new') { + throw new AccessDeniedException(); + } + + PageLayout::setTitle(Context::getHeaderLine() . ' - ' . _('Einrichtungsbild ändern')); + $this->institute_id = Context::getId(); + $avatar = InstituteAvatar::getAvatar($this->institute_id); + $this->avatar_url = $avatar->getURL(Avatar::NORMAL); + + } +} \ No newline at end of file diff --git a/app/controllers/institute/basicdata.php b/app/controllers/institute/basicdata.php index 9c800fdbbaa..0b7c2960201 100644 --- a/app/controllers/institute/basicdata.php +++ b/app/controllers/institute/basicdata.php @@ -35,8 +35,6 @@ class Institute_BasicdataController extends AuthenticatedController { PageLayout::setTitle(_('Verwaltung der Grunddaten')); - PageLayout::addSqueezePackage('avatar'); - //get ID from an open Institut $i_view = $i_id ?: Request::option('i_view', Context::getId()); diff --git a/app/controllers/settings/avatar.php b/app/controllers/settings/avatar.php new file mode 100644 index 00000000000..31bd7050233 --- /dev/null +++ b/app/controllers/settings/avatar.php @@ -0,0 +1,23 @@ +<?php + +class Settings_AvatarController extends AuthenticatedController +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + // Ensure user is logged in + $GLOBALS['auth']->login_if($action !== 'logout' && $GLOBALS['auth']->auth['uid'] === 'nobody'); + + if (!$GLOBALS['perm']->have_profile_perm('user', User::findCurrent()->id)) { + throw new AccessDeniedException(_('Sie dürfen dieses Profil nicht bearbeiten')); + } + } + public function index_action() + { + PageLayout::setTitle(_('Profilbild anpassen')); + Navigation::activateItem('/profile/edit/avatar'); + $this->user_id = User::findCurrent()->id; + $avatar = Avatar::getAvatar($this->user_id); + $this->avatar_url = $avatar->getURL(Avatar::NORMAL); + } +} \ No newline at end of file diff --git a/app/views/avatar/update.php b/app/views/avatar/update.php deleted file mode 100644 index f995702536e..00000000000 --- a/app/views/avatar/update.php +++ /dev/null @@ -1,75 +0,0 @@ -<?php -/** - * @var AvatarController $controller - * @var string $type - * @var string $id - * @var string $avatar - * @var bool $customized - * @var string $cancel_link - */ -?> -<form class="default settings-avatar" enctype="multipart/form-data" - action="<?= $controller->link_for('avatar/upload', $type, $id) ?>" method="post"> - <fieldset> - <legend> - <?= $type == 'user' ? _('Profilbild bearbeiten und zuschneiden') : - ($type == 'course' ? _('Veranstaltungsbild bearbeiten und zuschneiden') : - _('Einrichtungsbild bearbeiten und zuschneiden')) ?> - </legend> - <div class="form-group"> - <div id="avatar-preview"> - <img class="avatar-normal" id="new-avatar" src="<?= htmlReady($avatar) ?>" - data-message-too-small="<?= _('Das Bild ist kleiner als 250 x 250 Pixel. Wollen Sie wirklich fortfahren?') ?>"> - </div> - - <label class="file-upload"> - <?= _('Wählen Sie ein Bild von Ihrer Festplatte aus.') ?> - <input type="file" id="avatar-upload" accept="image/gif,image/png,image/jpeg,image/webp" - capture="camera" - data-max-size="<?= Avatar::MAX_FILE_SIZE ?>" - data-message-too-large="<?= _('Die hochgeladene Datei ist zu groß. Bitte wählen Sie ein anderes Bild.') ?>"> - - <p class="form-text"> - <?= sprintf( - _('Die Bilddatei darf max. %s groß sein, es sind nur Dateien mit den Endungen .jpg, .png, .gif und .webp erlaubt!'), - relsize(Avatar::MAX_FILE_SIZE) - ) ?> - </p> - - <a class="button" tabindex="0"><?= _('Auswählen') ?></a> - </label> - - <input type="hidden" name="cropped-image" id="cropped-image" value=""> - - <div id="avatar-buttons" class="hidden-js"> - <a href="" id="avatar-zoom-in" title="<?= _('Vergrößern') ?>"> - <?= Icon::create('zoom-in')->asImg(24) ?> - <?= _('Vergrößern') ?> - </a> - <a href="" id="avatar-zoom-out" title="<?= _('Verkleinern') ?>"> - <?= Icon::create('zoom-out')->asImg(24) ?> - <?= _('Verkleinern') ?> - </a> - <a href="" id="avatar-rotate-clockwise" title="<?= _('Nach rechts drehen') ?>"> - <?= Icon::create('rotate-right')->asImg(24) ?> - <?= _('Nach rechts drehen') ?> - </a> - <a href="" id="avatar-rotate-counter-clockwise" title="<?= _('Nach links drehen') ?>"> - <?= Icon::create('rotate-left')->asImg(24) ?> - <?= _('Nach links drehen') ?> - </a> - </div> - </div> - <?= CSRFProtection::tokenTag() ?> - </fieldset> - <footer data-dialog-button> - <?= Studip\Button::createAccept(_('Absenden'), 'upload', ['id' => 'submit-avatar']) ?> - <? if ($customized): ?> - <?= Studip\LinkButton::create( - _('Aktuelles Bild löschen'), - $controller->url_for('avatar/delete', $type, $id) - ) ?> - <? endif ?> - <?= Studip\LinkButton::createCancel(_('Abbrechen'), $cancel_link) ?> - </footer> -</form> diff --git a/app/views/course/avatar/index.php b/app/views/course/avatar/index.php new file mode 100644 index 00000000000..4899aad5353 --- /dev/null +++ b/app/views/course/avatar/index.php @@ -0,0 +1,7 @@ +<div + id="avatar-courses-app" + entry-type="courses" + entry-id="<?= $course_id ?>" + avatar-url="<?= $avatar_url ?>" +> +</div> \ No newline at end of file diff --git a/app/views/course/studygroup/avatar.php b/app/views/course/studygroup/avatar.php new file mode 100644 index 00000000000..ed3eeec2bed --- /dev/null +++ b/app/views/course/studygroup/avatar.php @@ -0,0 +1,7 @@ +<div + id="avatar-studygroups-app" + entry-type="courses" + entry-id="<?= $studygroup_id ?>" + avatar-url="<?= $avatar_url ?>" +> +</div> \ No newline at end of file diff --git a/app/views/institute/avatar/index.php b/app/views/institute/avatar/index.php new file mode 100644 index 00000000000..fee3c00718e --- /dev/null +++ b/app/views/institute/avatar/index.php @@ -0,0 +1,7 @@ +<div + id="avatar-institutes-app" + entry-type="institutes" + entry-id="<?= $institute_id ?>" + avatar-url="<?= $avatar_url ?>" +> +</div> \ No newline at end of file diff --git a/app/views/institute/basicdata/index.php b/app/views/institute/basicdata/index.php index abb048786ef..5c364918d7e 100644 --- a/app/views/institute/basicdata/index.php +++ b/app/views/institute/basicdata/index.php @@ -142,23 +142,3 @@ <input type="hidden" name="i_view" value="<?= $i_view ?>"> </footer> </form> - -<?php -$sidebar = Sidebar::get(); - -if (!$institute->isNew()) { - $widget = new ActionsWidget(); - $widget->addLink( - _('Infobild ändern'), - URLHelper::getURL('dispatch.php/avatar/update/institute/' . $institute->id), - Icon::create('edit') - )->asDialog(); - if (InstituteAvatar::getAvatar($institute->id)->is_customized()) { - $widget->addLink( - _('Infobild löschen'), - URLHelper::getURL('dispatch.php/avatar/delete/institute/' . $institute->id), - Icon::create('trash') - ); - } - $sidebar->addWidget($widget); -} diff --git a/app/views/profile/widget-avatar.php b/app/views/profile/widget-avatar.php index 157b74a1725..74b142c6a19 100644 --- a/app/views/profile/widget-avatar.php +++ b/app/views/profile/widget-avatar.php @@ -1,17 +1,11 @@ <div class="avatar-widget"> <? if ($GLOBALS['perm']->have_profile_perm('user', $current_user)) : ?> <a class="profile-avatar" - accept="image/gif,image/png,image/jpeg" capture="camera" - data-max-size="<?= Avatar::MAX_FILE_SIZE ?>" - data-message-too-large="<?= _('Die hochgeladene Datei ist zu groß. Bitte wählen Sie ein anderes Bild.') ?>" - data-message-unaccepted="<?= _('Die hochgeladene Datei hat falsche Typ. Bitte wählen Sie ein anderes Bild.') ?>" - href="<?= URLHelper::getURL('dispatch.php/avatar/update/user/' . $current_user) ?>" data-dialog> + href="<?= URLHelper::getURL('dispatch.php/settings/avatar/') ?>"> <?= $avatar->getImageTag(Avatar::NORMAL) ?> <div id="avatar-overlay" class="avatar-overlay"> <div class="text"> - <?= _('Bild hochladen oder löschen.') ?> - <br> - <?= _('Drag & Drop oder Klicken') ?> + <?= _('Profilbild ändern') ?> </div> </div> </a> diff --git a/app/views/settings/avatar/index.php b/app/views/settings/avatar/index.php new file mode 100644 index 00000000000..cee9ba495c5 --- /dev/null +++ b/app/views/settings/avatar/index.php @@ -0,0 +1,7 @@ +<div + id="avatar-users-app" + entry-type="users" + entry-id="<?= $user_id ?>" + avatar-url="<?= $avatar_url ?>" +> +</div> \ No newline at end of file diff --git a/lib/classes/Avatar.php b/lib/classes/Avatar.php index 959523f2ea1..d2dcd9d65c7 100644 --- a/lib/classes/Avatar.php +++ b/lib/classes/Avatar.php @@ -632,4 +632,9 @@ class Avatar imagedestroy($img); } } + + public function getId() + { + return $this->user_id; + } } diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 63c69e63059..e9a0a011a88 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -125,6 +125,7 @@ class RouteMap $this->addAuthenticatedCoursewareRoutes($group); } + $this->addAuthenticatedAvatarRoutes($group); $this->addAuthenticatedEventsRoutes($group); $this->addAuthenticatedFeedbackRoutes($group); $this->addAuthenticatedFilesRoutes($group); @@ -650,6 +651,14 @@ class RouteMap $group->post('/stock-images/{id}/blob', Routes\StockImages\StockImagesUpload::class); } + private function addAuthenticatedAvatarRoutes(RouteCollectorProxy $group): void + { + $group->get('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarOfRangeShow::class); + $group->delete('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarofRangeDelete::class); + + $group->post('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarUpload::class); + } + private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void { $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); diff --git a/lib/classes/JsonApi/Routes/Avatar/Authority.php b/lib/classes/JsonApi/Routes/Avatar/Authority.php new file mode 100644 index 00000000000..20166e702b4 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Avatar/Authority.php @@ -0,0 +1,29 @@ +<?php + +namespace JsonApi\Routes\Avatar; + +use Avatar; +use User; +use Course; +use Institute; + +class Authority +{ + public static function canShowAvatarOfRange(User $user, Avatar $resource): bool + { + return true; + } + + public static function canUpdateAvatarOfUser(User $user): bool + { + return $user->hasPermissionLevel('user', $user); + } + public static function canUpdateAvatarOfInstitute(User $user, Institute $institute): bool + { + return $user->hasPermissionLevel('admin', $institute); + } + public static function canUpdateAvatarOfSeminar(User $user, Course $course): bool + { + return $user->hasPermissionLevel('tutor', $course); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php b/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php new file mode 100644 index 00000000000..b288d08cb54 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Avatar/AvatarHelpers.php @@ -0,0 +1,40 @@ +<?php + +namespace JsonApi\Routes\Avatar; + +use JsonApi\Errors\RecordNotFoundException; + +trait AvatarHelpers +{ + + protected static function getAvatarClass(String $range_id, String $range_type, \User $user): Array + { + $has_perm = false; + $class = null; + + if ($range_type === 'users') { + $has_perm = Authority::canUpdateAvatarOfUser($user); + $class = \Avatar::class; + } else if ($range_type === 'institutes') { + $inst = \Institute::find($range_id); + if ($inst) { + $has_perm = Authority::canUpdateAvatarOfInstitute($user, $inst); + $class = \InstituteAvatar::class; + } + } else if ($range_type === 'courses') { + $course = \Course::find($range_id); + if ($course) { + $has_perm = Authority::canUpdateAvatarOfSeminar($user, $course); + if ($course->isStudygroup()) { + $class = \StudygroupAvatar::class; + } else { + $class = \CourseAvatar::class; + } + } + } else { + throw new RecordNotFoundException(); + } + + return ['class' => $class, 'has_perm' => $has_perm]; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php b/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php new file mode 100644 index 00000000000..285729594eb --- /dev/null +++ b/lib/classes/JsonApi/Routes/Avatar/AvatarOfRangeShow.php @@ -0,0 +1,34 @@ +<?php + +namespace JsonApi\Routes\Avatar; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +class AvatarOfRangeShow extends JsonApiController +{ + use AvatarHelpers; + public function __invoke(Request $request, Response $response, $args): Response + { + $range_id = $args['id']; + $range_type = $args['type']; + $user = $this->getUser($request); + + ['class' => $class] = self::getAvatarClass($range_id, $range_type, $user); + + $resource = $class::getAvatar($range_id); + + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowAvatarOfRange($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php b/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php new file mode 100644 index 00000000000..777d65c8bff --- /dev/null +++ b/lib/classes/JsonApi/Routes/Avatar/AvatarUpload.php @@ -0,0 +1,66 @@ +<?php + +namespace JsonApi\Routes\Avatar; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\ValidationTrait; +use JsonApi\NonJsonApiController; +use JsonApi\Routes\Files\RoutesHelperTrait as FilesRoutesHelper; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Slim\Psr7\UploadedFile; + +/** + * Create an Avatar. + */ +class AvatarUpload extends NonJsonApiController +{ + use ValidationTrait; + use AvatarHelpers; + public function __invoke(Request $request, Response $response, $args): Response + { + $user = $this->getUser($request); + $json = $this->validate($request); + $range_id = self::arrayGet($json, 'data.range-id'); + $range_type = self::arrayGet($json, 'data.range-type'); + + ['class' => $class, 'has_perm' => $has_perm] = self::getAvatarClass($range_id, $range_type, $user); + + if (!$has_perm) { + throw new AuthorizationFailedException(); + } + + $avatar = $class::getAvatar($range_id); + $imgdata_string = self::arrayGet($json, 'data.image'); + [$type, $imgdata_part] = explode(';', $imgdata_string); + [$base, $imgdata_base64] = explode(',', $imgdata_part); + $imgdata = base64_decode($imgdata_base64); + // Write data to file. + $filename = $GLOBALS['TMP_PATH'] . '/avatar-' . $range_id . '.webp'; + file_put_contents($filename, $imgdata); + + // Use new image file for avatar creation. + $avatar->createFrom($filename); + + + return $response->withStatus(201); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (!self::arrayHas($json, 'data.range-id')) { + return 'New avatar must have an `range-id`.'; + } + if (!self::arrayHas($json, 'data.range-type')) { + return 'New avatar must have a `range-type`.'; + } + if (!self::arrayHas($json, 'data.image')) { + return 'New avatar must have a `image`.'; + } + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php b/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php new file mode 100644 index 00000000000..05353cbbbb2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Avatar/AvatarofRangeDelete.php @@ -0,0 +1,36 @@ +<?php + +namespace JsonApi\Routes\Avatar; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one Avatar. + */ +class AvatarofRangeDelete extends JsonApiController +{ + use AvatarHelpers; + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $range_id = $args['id']; + $range_type = $args['type']; + $user = $this->getUser($request); + + ['class' => $class, 'has_perm' => $has_perm] = self::getAvatarClass($range_id, $range_type, $user); + + if (!$has_perm) { + throw new AuthorizationFailedException(); + } + + $class::getAvatar($range_id)->reset(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index 1498daf6925..ff5040dc57f 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -16,6 +16,8 @@ class SchemaMap \JsonApi\Models\ScheduleEntry::class => Schemas\ScheduleEntry::class, + \Avatar::class => Schemas\Avatar::class, + \BlubberComment::class => Schemas\BlubberComment::class, \BlubberStatusgruppeThread::class => Schemas\BlubberStatusgruppeThread::class, \BlubberThread::class => Schemas\BlubberThread::class, diff --git a/lib/classes/JsonApi/Schemas/Avatar.php b/lib/classes/JsonApi/Schemas/Avatar.php new file mode 100644 index 00000000000..5926f591a03 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Avatar.php @@ -0,0 +1,69 @@ +<?php + +namespace JsonApi\Schemas; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class Avatar extends SchemaProvider +{ + public const TYPE = 'avatar'; + const REL_RANGE = 'range'; + + public function getId($resource): ?string + { + return $resource->getId(); + } + + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'type' => $resource::AVATAR_TYPE, + 'customized' => $resource->is_customized(), + 'is-nobody' => $resource->isNobody(), + ]; + } + public function hasResourceMeta($resource): bool + { + return true; + } + + public function getResourceMeta($resource) + { + return [ + 'url' => [ + 'normal' => $resource->getURL(\Avatar::NORMAL), + 'medium' => $resource->getURL(\Avatar::MEDIUM), + 'small' => $resource->getURL(\Avatar::SMALL), + ] + ]; + } + + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + $range = self::getRange($resource->getId(), $resource::AVATAR_TYPE); + $relationships[self::REL_RANGE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($range), + ], + self::RELATIONSHIP_DATA => $range, + ]; + return $relationships; + } + + private function getRange(String $range_id, String $range_type) + { + switch ($range_type) { + case 'course': + case 'studygroup': + return \Course::build(['id' => $range_id], false); + case 'user': + return \User::build(['id' => $range_id], false); + case 'institute': + return \Institute::build(['id' => $range_id], false); + } + return null; + } +} \ No newline at end of file diff --git a/lib/modules/CoreAdmin.php b/lib/modules/CoreAdmin.php index 4bf1880561d..81849768a7a 100644 --- a/lib/modules/CoreAdmin.php +++ b/lib/modules/CoreAdmin.php @@ -38,8 +38,7 @@ class CoreAdmin extends CorePlugin implements StudipModule $item->setDescription(_('Bearbeiten der Grundeinstellungen dieser Veranstaltung.')); $navigation->addSubNavigation('details', $item); - $item = new Navigation(_('Infobild'), 'dispatch.php/avatar/update/course/' . $course_id); - $item->setImage(Icon::create('file-pic')); + $item = new Navigation(_('Veranstaltungsbild'), 'dispatch.php/course/avatar'); $item->setDescription(_('Infobild dieser Veranstaltung bearbeiten oder löschen.')); $navigation->addSubNavigation('avatar', $item); diff --git a/lib/modules/CoreStudygroupAdmin.php b/lib/modules/CoreStudygroupAdmin.php index d299adf1483..60819c9f02a 100644 --- a/lib/modules/CoreStudygroupAdmin.php +++ b/lib/modules/CoreStudygroupAdmin.php @@ -37,7 +37,7 @@ class CoreStudygroupAdmin extends CorePlugin implements StudipModule $navigation->addSubNavigation('contentmodules', new Navigation(_('Werkzeuge'), "dispatch.php/course/contentmodules?cid={$course_id}")); $navigation->addSubNavigation('main', new Navigation(_('Verwaltung'), "dispatch.php/course/studygroup/edit/?cid={$course_id}")); - $navigation->addSubNavigation('avatar', new Navigation(_('Infobild'), "dispatch.php/avatar/update/course/{$course_id}?cid={$course_id}")); + $navigation->addSubNavigation('avatar', new Navigation(_(' Studiengruppenbild'), "dispatch.php/course/studygroup/avatar?cid={$course_id}")); if (!$GLOBALS['perm']->have_perm('admin') && Config::get()->VOTE_ENABLE) { $item = new Navigation(_('Fragebögen'), 'dispatch.php/questionnaire/courseoverview'); diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php index 3e638766619..1dccf0efd13 100644 --- a/lib/navigation/AdminNavigation.php +++ b/lib/navigation/AdminNavigation.php @@ -72,6 +72,7 @@ class AdminNavigation extends Navigation $navigation->setURL('dispatch.php/institute/basicdata/index?cid='); $navigation->addSubNavigation('details', new Navigation(_('Grunddaten'), 'dispatch.php/institute/basicdata/index')); + $navigation->addSubNavigation('avatar', new Navigation(_('Einrichtungsbild'), 'dispatch.php/institute/avatar/index')); $navigation->addSubNavigation('faculty', new Navigation(_('Mitarbeiter'), 'dispatch.php/institute/members?admin_view=1')); $navigation->addSubNavigation('groups', new Navigation(_('Funktionen / Gruppen'), 'dispatch.php/admin/statusgroups?type=inst')); diff --git a/lib/navigation/ProfileNavigation.php b/lib/navigation/ProfileNavigation.php index 307cd986e54..2cf457437a9 100644 --- a/lib/navigation/ProfileNavigation.php +++ b/lib/navigation/ProfileNavigation.php @@ -62,6 +62,7 @@ class ProfileNavigation extends Navigation // profile data $navigation = new Navigation(_('Persönliche Angaben')); $navigation->addSubNavigation('profile', new Navigation(_('Grunddaten'), 'dispatch.php/settings/account')); + $navigation->addSubNavigation('avatar', new Navigation(_('Profilbild'), 'dispatch.php/settings/avatar')); if ( !StudipAuthAbstract::CheckField('auth_user_md5.password', $current_user->auth_plugin) && ( diff --git a/public/assets/images/icons/blue/flip.svg b/public/assets/images/icons/blue/flip.svg new file mode 100644 index 00000000000..3e3bd454a05 --- /dev/null +++ b/public/assets/images/icons/blue/flip.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="16" + height="16" + viewBox="0 0 54 54" + fill="#28497c" + version="1.1" + id="svg1" + sodipodi:docname="flip.svg" + inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs1" /> + <sodipodi:namedview + id="namedview1" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:zoom="22.734375" + inkscape:cx="-8.4673539" + inkscape:cy="9.0831615" + inkscape:window-width="4300" + inkscape:window-height="1711" + inkscape:window-x="3591" + inkscape:window-y="-9" + inkscape:window-maximized="1" + inkscape:current-layer="svg1" /> + <path + d="m 5.5246542,50.625 c -0.7581122,0 -1.4601614,-0.506298 -1.847644,-1.332805 -0.3874829,-0.826146 -0.4031039,-1.85001 -0.041102,-2.694691 L 21.549628,4.7995235 c 0.469484,-1.0955038 1.465493,-1.6474176 2.418507,-1.3402576 0.952728,0.30716 1.619981,1.3951039 1.619981,2.6419528 V 47.899052 c 0,1.50545 -0.962473,2.725948 -2.149656,2.725948 z" + fill="#212121" + id="path1" + style="fill:#28497c;fill-opacity:1;stroke-width:3.22762" /> + <path + style="fill:#28497c;fill-opacity:1;stroke-width:0.053239" + d="m 30.099579,50.504015 c -0.76301,-0.260211 -1.349993,-0.988992 -1.539376,-1.911246 -0.06565,-0.319689 -0.07338,-2.596896 -0.07338,-21.622388 0,-20.6602267 0.0025,-21.2753879 0.08907,-21.6279742 0.12269,-0.4998832 0.282609,-0.8248327 0.583559,-1.1857624 0.456209,-0.5471316 0.951945,-0.7744407 1.580519,-0.7247116 0.619496,0.04901 1.24274,0.4786448 1.570219,1.0824358 0.229538,0.4232141 18.145117,42.2636284 18.2324,42.5803254 0.115579,0.419371 0.108551,1.214107 -0.01461,1.651927 -0.248621,0.883811 -0.797929,1.528143 -1.500662,1.76025 -0.245569,0.08111 -1.358206,0.09144 -9.475636,0.08796 -7.875707,-0.0034 -9.233994,-0.01643 -9.452109,-0.09081 z M 44.886923,45.068188 C 44.687305,44.586157 32.943681,17.188 32.854222,16.995607 l -0.111509,-0.239809 -4.33e-4,14.223642 -4.33e-4,14.223642 h 6.100484 6.100482 z" + id="path3" /> +</svg> diff --git a/resources/assets/javascripts/bootstrap/avatar.js b/resources/assets/javascripts/bootstrap/avatar.js index cbda58851fe..31d724dec53 100644 --- a/resources/assets/javascripts/bootstrap/avatar.js +++ b/resources/assets/javascripts/bootstrap/avatar.js @@ -1,54 +1,17 @@ STUDIP.domReady(() => { - STUDIP.Avatar.init('#avatar-upload'); - - // Get file data on drop - var dropZone = document.getElementById('avatar-overlay'); - - if (dropZone) { - dropZone.addEventListener('dragover', function(e) { - e.stopPropagation(); - e.preventDefault(); - e.target.parentNode.classList.add("dragging"); - }); - - dropZone.addEventListener('dragleave', function(e) { - e.stopPropagation(); - e.preventDefault(); - e.target.parentNode.classList.remove("dragging"); - }); - - dropZone.addEventListener('drop', function(e) { - e.stopPropagation(); - e.preventDefault(); - e.target.parentNode.classList.remove("dragging"); - var files = e.dataTransfer.files; - var div = e.target.parentNode; - var avatar_dialog = div.getElementsByTagName('a')[0]; - - if (!div.getAttribute('accept') || !div.getAttribute('accept').includes(files[0].type)) { - alert(div.getAttribute('data-message-unaccepted')); - return false; - } - - if (!div.getAttribute('data-max-size') || files[0].size > div.getAttribute('data-max-size')) { - alert(div.getAttribute('data-message-too-large')); - return false; - } - - avatar_dialog.click(); - div.files = files; - STUDIP.dialogReady(() => { - STUDIP.Avatar.readFile(div); + const avatarTypes = ['courses', 'institutes', 'studygroups', 'users']; + + avatarTypes.forEach((type) => { + if (document.getElementById(`avatar-${type}-app`)) { + Promise.all([ + STUDIP.loadChunk('avatar'), + import( + /* webpackChunkName: "avatar-app" */ + '@/vue/avatar-app.js' + ), + ]).then(([{ createApp }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, `#avatar-${type}-app`); }); - }); - } - - //"Redirecting" the event is necessary so that the avatar image upload - //is accessible by pressing the enter key when its focused. - jQuery(document).on('keydown', 'form.settings-avatar label.file-upload a.button', function(event) { - if (event.code == "Enter") { - //The enter key has been pressed. - jQuery(this).parent('.file-upload').trigger('click'); } }); }); diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js index f3722866a86..995b35f4d8f 100644 --- a/resources/assets/javascripts/chunk-loader.js +++ b/resources/assets/javascripts/chunk-loader.js @@ -21,15 +21,6 @@ let mathjax_promise = null; export const loadChunk = function (chunk, { silent = false } = {}) { let promise = null; switch (chunk) { - case 'code-highlight': - promise = import( - /* webpackChunkName: "code-highlight" */ - './chunks/code-highlight' - ).then(({ default: hljs }) => { - return hljs; - }); - break; - case 'courseware': promise = Promise.all([ STUDIP.loadChunk('vue'), @@ -40,6 +31,25 @@ export const loadChunk = function (chunk, { silent = false } = {}) { ]).then(([Vue]) => Vue); break; + case 'avatar': + promise = Promise.all([ + STUDIP.loadChunk('vue'), + import( + /* webpackChunkName: "avatar" */ + './chunks/avatar' + ), + ]).then(([Vue]) => Vue); + break; + + case 'code-highlight': + promise = import( + /* webpackChunkName: "code-highlight" */ + './chunks/code-highlight' + ).then(({default: hljs}) => { + return hljs; + }); + break; + case 'chartist': promise = import( /* webpackChunkName: "chartist" */ diff --git a/resources/assets/javascripts/chunks/avatar.js b/resources/assets/javascripts/chunks/avatar.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/resources/vue/avatar-app.js b/resources/vue/avatar-app.js new file mode 100644 index 00000000000..0092ebe5f83 --- /dev/null +++ b/resources/vue/avatar-app.js @@ -0,0 +1,76 @@ +import AvatarApp from './components/avatar/AvatarApp.vue'; +import AvatarModule from './store/avatar.module'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import axios from 'axios'; + +const mountApp = async (STUDIP, createApp, element) => { + + let entry_id = null; + let entry_type = null; + let avatar_url = null; + let elem; + + if ((elem = document.getElementById(element.substring(1))) !== undefined) { + if (elem.attributes !== undefined) { + if (elem.attributes['entry-type'] !== undefined) { + entry_type = elem.attributes['entry-type'].value; + } + + if (elem.attributes['entry-id'] !== undefined) { + entry_id = elem.attributes['entry-id'].value; + } + + if (elem.attributes['avatar-url'] !== undefined) { + avatar_url = elem.attributes['avatar-url'].value; + } + } + } + + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + 'avatar-module': AvatarModule, + ...mapResourceModules({ + names: [ + 'avatar', + 'courses', + 'institutes', + 'stock-images', + 'studygroups', + 'users', + ], + httpClient, + }), + } + }); + + const context = { + type: entry_type, + id: entry_id + } + store.dispatch('setUserId', STUDIP.USER_ID); + await store.dispatch('users/loadById', { id: STUDIP.USER_ID }); + store.dispatch('setHttpClient', httpClient); + store.dispatch('setContext', context); + const avatar = await store.dispatch('loadAvatar'); + + const app = createApp({ + render: (h) => h(AvatarApp), + store, + }); + app.$mount(element); + + return app; +} + +export default mountApp; \ No newline at end of file diff --git a/resources/vue/components/avatar/AvatarApp.vue b/resources/vue/components/avatar/AvatarApp.vue new file mode 100644 index 00000000000..0e89b5e3c4b --- /dev/null +++ b/resources/vue/components/avatar/AvatarApp.vue @@ -0,0 +1,430 @@ +<template> + <div class="avatar"> + <section class="contentbox"> + <header> + <h1>{{ avatarAltText }}</h1> + </header> + <section v-if="!editingImage" class="avatar-display"> + <img class="avatar-original" :src="avatarUrl" :alt="avatarAltText" /> + <div class="button-wrapper"> + <button class="button edit" @click="changeImage"> + <span v-if="isCustomized">{{ $gettext('Ändern') }}</span + ><span v-else>{{ $gettext('Bild wählen') }}</span> + </button> + <button v-if="isCustomized" class="button trash" @click="showRemoveDialog = true"> + {{ $gettext('Löschen') }} + </button> + </div> + </section> + <section v-show="editingImage" class="avatar-edit"> + <div class="cropper-container"> + <img ref="newImage" src="" @zoom="checkImageSize" /> + </div> + <studip-message-box v-if="invalid" type="warning" :hideClose="false">{{ + $gettext( + 'Bildauswahl zu klein. Bitte wählen Sie einen größeren Ausschnitt oder ein anderes Bild aus.' + ) + }}</studip-message-box> + <div class="cropper-actions-wrapper"> + <div class="cropper-actions-group"> + <div class="labeled-range-input"> + <label for="cropper-rotate" class="sr-only">{{ $gettext('Neigungswähler') }}</label> + <input + id="cropper-rotate" + type="range" + min="-45" + max="45" + steps="1" + v-model="cropperRotate" + @input="updateCropperRotate" + /> + <div class="labeled-range-input-labels"> + <span>-45°</span> + <span>0°</span> + <span>+45°</span> + </div> + </div> + </div> + <div class="cropper-actions-group"> + <button + class="cropper-actions-button" + @click="updateCropperBaseRotation(90)" + :title="$gettext('nach rechts drehen')" + > + <StudipIcon shape="rotate-right" :size="24" /> + </button> + <button + class="cropper-actions-button" + @click="updateCropperBaseRotation(-90)" + :title="$gettext('nach links drehen')" + > + <StudipIcon shape="rotate-left" :size="24" /> + </button> + <button + class="cropper-actions-button" + @click="zoomCropper(+0.1)" + :title="$gettext('vergrößern')" + > + <StudipIcon shape="zoom-in2" :size="24" /> + </button> + <button + class="cropper-actions-button" + @click="zoomCropper(-0.1)" + :title="$gettext('verkleinern')" + > + <StudipIcon shape="zoom-out2" :size="24" /> + </button> + <button class="cropper-actions-button" @click="flip()" :title="$gettext('horizontal spiegeln')"> + <StudipIcon shape="flip" :size="24" /> + </button> + <button class="cropper-actions-button" @click="resetCropper" :title="$gettext('zurücksetzen')"> + <StudipIcon shape="refresh" :size="24" /> + </button> + <button class="cropper-actions-button" @click="changeImage" :title="$gettext('Bild auswählen')"> + <StudipIcon shape="upload" :size="24" /> + </button> + </div> + <div class="cropper-actions-group"> + <button class="button accept" @click="storeAvatar" :disabled="invalid"> + {{ $gettext('Speichern') }} + </button> + <button class="button cancel" @click="abortEditing">{{ $gettext('Abbrechen') }}</button> + </div> + </div> + </section> + </section> + <input + id="avatar-upload" + ref="uploadFile" + type="file" + accept="image/gif,image/png,image/jpeg,image/webp;capture=camera" + @change="updateUploadImage" + /> + <studip-dialog + v-if="showRemoveDialog" + :title="$gettext('Bild löschen')" + :question="$gettext('Möchten Sie dieses Bild wirklich löschen')" + height="180" + width="360" + @confirm="removeAvatar" + @close="showRemoveDialog = false" + ></studip-dialog> + <studip-dialog + v-if="showChangeImageDialog" + :title="$gettext('Bildquelle auswählen')" + :closeText="$gettext('Abbrechen')" + closeClass="cancel" + height="310" + @close="showChangeImageDialog = false" + > + <template v-slot:dialogContent> + <div class="square-button-panel"> + <studip-square-button + icon="upload" + :title="$gettext('Bild hochladen')" + @click="selectUploadImage" + ></studip-square-button> + <studip-square-button + icon="block-gallery" + :title="$gettext('Aus Bilderpool auswählen')" + @click="selectStockImage" + ></studip-square-button> + </div> + </template> + </studip-dialog> + <StockImageSelector + v-if="showStockImageSelector" + @close="showStockImageSelector = false" + @select="onSelectStockImage" + /> + </div> +</template> + +<script> +import axios from 'axios'; +import StudipDialog from '../StudipDialog.vue'; +import StudipMessageBox from '../StudipMessageBox.vue'; +import StudipSquareButton from '../StudipSquareButton.vue'; +import StockImageSelector from '../stock-images/SelectorDialog.vue'; +import { mapGetters, mapActions } from 'vuex'; + +import Cropper from 'cropperjs'; + +export default { + components: { + StudipDialog, + StudipMessageBox, + StudipSquareButton, + StockImageSelector, + }, + data() { + return { + showRemoveDialog: false, + showChangeImageDialog: false, + showStockImageSelector: false, + selectedStockImage: false, + uploadImage: null, + editingImage: false, + base64Image: null, + cropper: null, + invalid: false, + cropperRotate: 0, + cropperBaseRotation: 0, + fliped: false, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + currentAvatar: 'currentAvatar', + isCourseAvatar: 'isCourseAvatar', + isInstituteAvatar: 'isInstituteAvatar', + isStudygroupAvatar: 'isStudygroupAvatar', + isUserAvatar: 'isUserAvatar', + isCustomized: 'isCustomized', + }), + avatarUrl() { + return this.currentAvatar.meta.url.normal; + }, + avatarAltText() { + if (this.isUserAvatar) { + return this.$gettext('Mein Profilbild'); + } + if (this.isCourseAvatar) { + return this.$gettext('Veranstaltungsbild'); + } + if (this.isStudygroupAvatar) { + return this.$gettext('Studiengruppenbild'); + } + if (this.isInstituteAvatar) { + return this.$gettext('Einrichtungsbild'); + } + return ''; + }, + }, + methods: { + changeImage() { + if (this.isUserAvatar) { + this.selectUploadImage(); + } else { + this.showChangeImageDialog = true; + } + }, + selectUploadImage() { + this.showChangeImageDialog = false; + this.$refs.uploadFile.click(); + }, + selectStockImage() { + this.showChangeImageDialog = false; + this.showStockImageSelector = true; + }, + onSelectStockImage(stockImage) { + if (this.cropper) { + this.resetCropper(); + this.cropper.destroy(); + } + this.base64Image = null; + this.uploadImage = null; + this.selectedStockImage = stockImage; + this.showStockImageSelector = false; + this.loadStockImage(); + }, + updateUploadImage() { + if (this.cropper) { + this.resetCropper(); + this.cropper.destroy(); + } + this.base64Image = null; + this.createBase64Image(this.$refs.uploadFile.files[0]); + }, + createBase64Image(FileObject) { + const reader = new FileReader(); + reader.onload = (event) => { + this.base64Image = event.target.result; + this.enableCropper(); + }; + reader.readAsDataURL(FileObject); + }, + enableCropper() { + let image = this.$refs.newImage; + image.src = this.base64Image; + this.cropper = new Cropper(image, { + aspectRatio: 1, + viewMode: 1, + autoCropArea: 0.9, + dragMode: 'move', + cropBoxMovable: false, + cropBoxResizable: false, + toggleDragModeOnDblclick: false, + }); + this.editingImage = true; + }, + checkImageSize() { + const data = this.cropper.getData(); + if (data.width < 250 || data.height < 250) { + this.invalid = true; + } else { + this.invalid = false; + } + }, + updateCropperBaseRotation(val) { + this.cropperBaseRotation += val; + if (this.cropperBaseRotation > 270) { + this.cropperBaseRotation = 0; + } + this.cropperRotate = 0; + this.cropper.rotateTo(this.cropperBaseRotation); + }, + updateCropperRotate() { + this.cropper.rotateTo(parseInt(this.cropperRotate) + this.cropperBaseRotation); + }, + resetCropper() { + this.cropper.reset(); + this.fliped = false; + this.cropperBaseRotation = 0; + this.cropperRotate = 0; + }, + zoomCropper(val) { + this.cropper.zoom(val); + }, + flip() { + this.fliped ? this.cropper.scale(1, 1) : this.cropper.scale(-1, 1); + this.fliped = !this.fliped; + }, + abortEditing() { + this.resetCropper(); + this.cropper.destroy(); + this.selectedStockImage = null; + this.$refs.uploadFile.value = ''; + this.base64Image = null; + this.editingImage = false; + this.fliped = false; + }, + + loadStockImage() { + const url = this.selectedStockImage.attributes['download-urls'].original; + fetch(url) + .then((response) => response.blob()) + .then( + (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }) + ) + .then((base64) => { + this.base64Image = base64; + this.enableCropper(); + }); + }, + + storeAvatar() { + if (this.invalid) { + return false; + } + const croppedImage = this.cropper.getCroppedCanvas().toDataURL('image/webp'); + const data = { + data: { + 'range-id': this.context.id, + 'range-type': this.context.type, + image: croppedImage, + }, + }; + + axios.post(STUDIP.URLHelper.getURL(`jsonapi.php/v1/${this.context.type}/${this.context.id}/avatar`, {}, true), data).then((response) => { + location.reload(); + }); + }, + + removeAvatar() { + axios + .delete( + STUDIP.URLHelper.getURL(`jsonapi.php/v1/${this.context.type}/${this.context.id}/avatar`, {}, true) + ) + .then((response) => { + location.reload(); + }); + }, + }, +}; +</script> +<style scoped lang="scss"> +.avatar { + max-width: 520px; +} + +.avatar-original { + max-width: 500px; +} +#avatar-upload { + display: none; +} +.cropper-container { + width: 500px; + height: 500px; + margin-bottom: 1em; +} +.square-button-panel { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; + justify-content: center; +} + +.cropper-actions-wrapper { + max-width: 500px; + + .cropper-actions-group { + display: flex; + justify-content: space-between; + margin: 1em 0; + + .cropper-actions-button { + background-color: transparent; + border: none; + cursor: pointer; + } + + .labeled-range-input { + width: 100%; + input[type='range'] { + width: 100%; + -webkit-appearance: none; + appearance: none; + cursor: pointer; + height: 2px; + background: var(--content-color-40); + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 16px; + width: 16px; + background-color: var(--base-color); + border-radius: 50%; + border: none; + } + + &::-moz-range-thumb { + height: 16px; + width: 16px; + background-color: var(--base-color); + border-radius: 50%; + border: none; + } + } + + .labeled-range-input-labels { + display: flex; + justify-content: space-between; + span { + margin-left: 0.5em; + } + } + } + } +} +</style> diff --git a/resources/vue/store/avatar.module.js b/resources/vue/store/avatar.module.js new file mode 100644 index 00000000000..7a45576b283 --- /dev/null +++ b/resources/vue/store/avatar.module.js @@ -0,0 +1,102 @@ +const getDefaultState = () => { + return { + context: null, + httpClient: null, + userId: null, + }; +}; + +const initialState = getDefaultState(); + +const getters = { + context(state) { + return state.context; + }, + httpClient(state) { + return state.httpClient; + }, + userId(state) { + return state.userId; + }, + + currentAvatar(state, getters, rootState, rootGetters) { + if (getters.context === null) { + return null; + } + const parent = { + type: getters.context.type, + id: getters.context.id, + }; + + const relationship = 'avatar'; + + return rootGetters['avatar/related']({ parent, relationship }); + }, + currentUser(state, getters, rootState, rootGetters) { + const id = getters.userId; + return rootGetters['users/byId']({ id }); + }, + isCourseAvatar(state, getters) { + return getters.context?.type === 'courses'; + }, + isInstituteAvatar(state, getters) { + return getters.context?.type === 'institutes'; + }, + isStudygroupAvatar(state, getters) { + return getters.context?.type === 'studygroups'; + }, + isUserAvatar(state, getters) { + return getters.context?.type === 'users'; + }, + isCustomized(state, getters) { + return getters.currentAvatar.attributes.customized; + } +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + setContext({ commit }, context) { + commit('setContext', context); + }, + setHttpClient({ commit }, httpClient) { + commit('setHttpClient', httpClient); + }, + setUserId({ commit }, userId) { + commit('setUserId', userId); + }, + + // other actions + loadAvatar({ dispatch, getters, rootGetters }) { + const parent = { + type: getters.context.type, + id: getters.context.id, + }; + + const relationship = 'avatar'; + + return dispatch('avatar/loadRelated', { parent, relationship }, { root: true }).then(() => { + rootGetters['avatar/related']({ parent, relationship }); + }); + }, +}; + +export const mutations = { + setContext(state, context) { + state.context = context; + }, + setHttpClient(state, httpClient) { + state.httpClient = httpClient; + }, + setUserId(state, data) { + state.userId = data; + }, +}; + +export default { + state, + actions, + mutations, + getters, +}; -- GitLab