From abc0f7cf7deda0970f6f7be04208358bf16fbbc0 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Tue, 21 Jan 2025 00:04:23 -0500 Subject: [PATCH] Course: Add custom image support for course links in homepage tools - refs #2863 --- assets/vue/components/course/ShortCutList.vue | 2 +- assets/vue/components/links/LinkForm.vue | 65 ++++++++- assets/vue/services/linkService.js | 14 ++ assets/vue/views/course/CourseHome.vue | 10 +- .../Controller/Api/CLinkDetailsController.php | 9 +- .../Controller/Api/CLinkImageController.php | 126 ++++++++++++++++++ .../Controller/CourseController.php | 20 +++ src/CoreBundle/Entity/Asset.php | 1 + .../Schema/V200/Version20250118000100.php | 36 +++++ src/CourseBundle/Entity/CLink.php | 83 +++++++++--- src/CourseBundle/Entity/CShortcut.php | 16 +++ 11 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 src/CoreBundle/Controller/Api/CLinkImageController.php create mode 100644 src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php diff --git a/assets/vue/components/course/ShortCutList.vue b/assets/vue/components/course/ShortCutList.vue index 8d1ea5331c5..ed3fdf650ed 100644 --- a/assets/vue/components/course/ShortCutList.vue +++ b/assets/vue/components/course/ShortCutList.vue @@ -6,7 +6,7 @@ > diff --git a/assets/vue/components/links/LinkForm.vue b/assets/vue/components/links/LinkForm.vue index 3541329a2f1..9fda12eb0a6 100644 --- a/assets/vue/components/links/LinkForm.vue +++ b/assets/vue/components/links/LinkForm.vue @@ -43,6 +43,38 @@ option-value="value" /> +
+
+

{{ t("Current Image") }}

+ Custom Image + +
+ + +

+ {{ t("This image will serve as the icon for the link displayed as a tool on the course homepage.") }} +

+

{{ t("Image must be 120x120 pixels.") }}

+
+ { formData.target = response.target formData.parentResourceNodeId = response.parentResourceNodeId formData.resourceLinkList = response.resourceLinkList + + if (response.customImageUrl) { + formData.customImageUrl = response.customImageUrl + } + if (response.category) { formData.category = parseInt(response.category["@id"].split("/").pop()) } @@ -155,6 +197,11 @@ const fetchLink = async () => { } } +const removeCurrentImage = () => { + formData.customImageUrl = null + formData.removeImage = true +} + const submitForm = async () => { v$.value.$touch() @@ -180,8 +227,23 @@ const submitForm = async () => { try { if (props.linkId) { await linkService.updateLink(props.linkId, postData) + + const formDataImage = new FormData() + formDataImage.append("removeImage", formData.removeImage ? "true" : "false") + + if (selectedFile.value instanceof File) { + formDataImage.append("customImage", selectedFile.value) + } + + await linkService.uploadImage(props.linkId, formDataImage) } else { - await linkService.createLink(postData) + const newLink = await linkService.createLink(postData) + + if (selectedFile.value instanceof File) { + const formDataImage = new FormData() + formDataImage.append("customImage", selectedFile.value) + await linkService.uploadImage(newLink.iid, formDataImage) + } } notification.showSuccessNotification(t("Link saved")) @@ -192,6 +254,7 @@ const submitForm = async () => { }) } catch (error) { console.error("Error updating link:", error) + notification.showErrorNotification(t("Error saving the link")) } } diff --git a/assets/vue/services/linkService.js b/assets/vue/services/linkService.js index 0221801a167..d85e1589c68 100644 --- a/assets/vue/services/linkService.js +++ b/assets/vue/services/linkService.js @@ -3,6 +3,20 @@ import axios from "axios" import baseService from "./baseService" export default { + /** + * @param {Number|String} linkId + * @param {FormData} imageData + */ + uploadImage: async (linkId, imageData) => { + const endpoint = `${ENTRYPOINT}links/${linkId}/upload-image` + const response = await axios.post(endpoint, imageData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + return response.data + }, + /** * @param {Object} params */ diff --git a/assets/vue/views/course/CourseHome.vue b/assets/vue/views/course/CourseHome.vue index 33ae731dce8..98913c50b24 100644 --- a/assets/vue/views/course/CourseHome.vue +++ b/assets/vue/views/course/CourseHome.vue @@ -315,9 +315,15 @@ courseService.loadCTools(course.value.id, session.value?.id).then((cTools) => { courseService .loadTools(course.value.id, session.value?.id) .then((data) => { - shortcuts.value = data.shortcuts + shortcuts.value = data.shortcuts.map((shortcut) => { + return { + ...shortcut, + customImageUrl: shortcut.customImageUrl || null, + } + }) }) - .catch((error) => console.log(error)) + .catch((error) => console.error(error)) + const courseTMenu = ref(null) diff --git a/src/CoreBundle/Controller/Api/CLinkDetailsController.php b/src/CoreBundle/Controller/Api/CLinkDetailsController.php index d7d3f2a8ab8..096280f3cbc 100644 --- a/src/CoreBundle/Controller/Api/CLinkDetailsController.php +++ b/src/CoreBundle/Controller/Api/CLinkDetailsController.php @@ -6,6 +6,7 @@ namespace Chamilo\CoreBundle\Controller\Api; +use Chamilo\CoreBundle\Repository\AssetRepository; use Chamilo\CourseBundle\Entity\CLink; use Chamilo\CourseBundle\Repository\CShortcutRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,7 +14,7 @@ class CLinkDetailsController extends AbstractController { - public function __invoke(CLink $link, CShortcutRepository $shortcutRepository): Response + public function __invoke(CLink $link, CShortcutRepository $shortcutRepository, AssetRepository $assetRepository): Response { $shortcut = $shortcutRepository->getShortcutFromResource($link); $isOnHomepage = null !== $shortcut; @@ -45,6 +46,12 @@ public function __invoke(CLink $link, CShortcutRepository $shortcutRepository): 'category' => $link->getCategory()?->getIid(), ]; + if (null !== $link->getCustomImage()) { + $details['customImageUrl'] = $assetRepository->getAssetUrl($link->getCustomImage()); + } else { + $details['customImageUrl'] = null; + } + return $this->json($details, Response::HTTP_OK); } } diff --git a/src/CoreBundle/Controller/Api/CLinkImageController.php b/src/CoreBundle/Controller/Api/CLinkImageController.php new file mode 100644 index 00000000000..cc5db5e4ede --- /dev/null +++ b/src/CoreBundle/Controller/Api/CLinkImageController.php @@ -0,0 +1,126 @@ +entityManager = $entityManager; + } + + public function __invoke(CLink $link, Request $request): Response + { + $removeImage = $request->request->getBoolean('removeImage', false); + $file = $request->files->get('customImage'); + + if ($removeImage) { + if ($link->getCustomImage()) { + $this->entityManager->remove($link->getCustomImage()); + $link->setCustomImage(null); + $this->entityManager->persist($link); + $this->entityManager->flush(); + + if (!$file) { + return new Response('Image removed successfully', Response::HTTP_OK); + } + } + } + + if (!$file || !$file->isValid()) { + return new Response('Invalid or missing file', Response::HTTP_BAD_REQUEST); + } + + try { + $asset = new Asset(); + $asset->setFile($file) + ->setCategory(Asset::LINK) + ->setTitle($file->getClientOriginalName()); + + $this->entityManager->persist($asset); + $this->entityManager->flush(); + + $uploadedFilePath = $file->getPathname(); + + $croppedFilePath = $this->cropImage($uploadedFilePath); + + if (!file_exists($croppedFilePath)) { + @unlink($uploadedFilePath); + return new Response('Error creating cropped image', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $asset->setFile(new File($croppedFilePath)); + $this->entityManager->persist($asset); + $this->entityManager->flush(); + + $link->setCustomImage($asset); + $this->entityManager->persist($link); + $this->entityManager->flush(); + + return new Response('Image uploaded and linked successfully', Response::HTTP_OK); + + } catch (\Exception $e) { + return new Response('Error processing image: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + private function cropImage(string $filePath): string + { + [$originalWidth, $originalHeight, $imageType] = getimagesize($filePath); + + if (!$originalWidth || !$originalHeight) { + throw new \RuntimeException('Invalid image file'); + } + + switch ($imageType) { + case IMAGETYPE_JPEG: + $sourceImage = imagecreatefromjpeg($filePath); + break; + case IMAGETYPE_PNG: + $sourceImage = imagecreatefrompng($filePath); + break; + case IMAGETYPE_GIF: + $sourceImage = imagecreatefromgif($filePath); + break; + default: + throw new \RuntimeException('Unsupported image type'); + } + + $croppedImage = imagecreatetruecolor(120, 120); + + $cropWidth = min($originalWidth, $originalHeight); + $cropHeight = $cropWidth; + $srcX = (int) (($originalWidth - $cropWidth) / 2); + $srcY = (int) (($originalHeight - $cropHeight) / 2); + + imagecopyresampled( + $croppedImage, + $sourceImage, + 0, 0, + $srcX, $srcY, + $cropWidth, $cropHeight, + 120, 120 + ); + + $croppedFilePath = sys_get_temp_dir() . '/' . uniqid('cropped_', true) . '.png'; + imagepng($croppedImage, $croppedFilePath); + + imagedestroy($sourceImage); + imagedestroy($croppedImage); + + return $croppedFilePath; + } +} diff --git a/src/CoreBundle/Controller/CourseController.php b/src/CoreBundle/Controller/CourseController.php index 7013fcc2897..5b7a5526ae0 100644 --- a/src/CoreBundle/Controller/CourseController.php +++ b/src/CoreBundle/Controller/CourseController.php @@ -15,6 +15,7 @@ use Chamilo\CoreBundle\Entity\Tool; use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Framework\Container; +use Chamilo\CoreBundle\Repository\AssetRepository; use Chamilo\CoreBundle\Repository\CourseCategoryRepository; use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository; use Chamilo\CoreBundle\Repository\LanguageRepository; @@ -30,6 +31,8 @@ use Chamilo\CoreBundle\Tool\ToolChain; use Chamilo\CourseBundle\Controller\ToolBaseController; use Chamilo\CourseBundle\Entity\CCourseDescription; +use Chamilo\CourseBundle\Entity\CLink; +use Chamilo\CourseBundle\Entity\CShortcut; use Chamilo\CourseBundle\Entity\CTool; use Chamilo\CourseBundle\Entity\CToolIntro; use Chamilo\CourseBundle\Repository\CCourseDescriptionRepository; @@ -133,6 +136,7 @@ public function indexJson( Request $request, CShortcutRepository $shortcutRepository, EntityManagerInterface $em, + AssetRepository $assetRepository ): Response { $requestData = json_decode($request->getContent(), true); // Sort behaviour @@ -214,6 +218,22 @@ public function indexJson( if (null !== $user) { $shortcutQuery = $shortcutRepository->getResources($course->getResourceNode()); $shortcuts = $shortcutQuery->getQuery()->getResult(); + + /* @var CShortcut $shortcut */ + foreach ($shortcuts as $shortcut) { + $resourceNode = $shortcut->getShortCutNode(); + $cLink = $em->getRepository(CLink::class)->findOneBy(['resourceNode' => $resourceNode]); + + if ($cLink) { + $shortcut->setCustomImageUrl( + $cLink->getCustomImage() + ? $assetRepository->getAssetUrl($cLink->getCustomImage()) + : null + ); + } else { + $shortcut->setCustomImageUrl(null); + } + } } $responseData = [ 'shortcuts' => $shortcuts, diff --git a/src/CoreBundle/Entity/Asset.php b/src/CoreBundle/Entity/Asset.php index 435f3209e6c..41124c8b674 100644 --- a/src/CoreBundle/Entity/Asset.php +++ b/src/CoreBundle/Entity/Asset.php @@ -39,6 +39,7 @@ class Asset implements Stringable public const SYSTEM_TEMPLATE = 'system_template'; public const TEMPLATE = 'template'; public const SESSION = 'session'; + public const LINK = 'link'; #[ORM\Id] #[ORM\Column(type: 'uuid')] diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php b/src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php new file mode 100644 index 00000000000..fab95ed941c --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php @@ -0,0 +1,36 @@ +addSql(' + ALTER TABLE c_link + ADD custom_image_id BINARY(16) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', + ADD CONSTRAINT FK_9209C2A0D877C209 FOREIGN KEY (custom_image_id) REFERENCES asset (id) ON DELETE SET NULL + '); + } + + public function down(Schema $schema): void + { + // Remove the custom_image_id column and foreign key + $this->addSql(' + ALTER TABLE c_link + DROP FOREIGN KEY FK_9209C2A0D877C209, + DROP custom_image_id + '); + } +} diff --git a/src/CourseBundle/Entity/CLink.php b/src/CourseBundle/Entity/CLink.php index 0bbc31e47df..5c0256a9c87 100644 --- a/src/CourseBundle/Entity/CLink.php +++ b/src/CourseBundle/Entity/CLink.php @@ -17,12 +17,14 @@ use ApiPlatform\Metadata\Put; use Chamilo\CoreBundle\Controller\Api\CheckCLinkAction; use Chamilo\CoreBundle\Controller\Api\CLinkDetailsController; +use Chamilo\CoreBundle\Controller\Api\CLinkImageController; use Chamilo\CoreBundle\Controller\Api\CreateCLinkAction; use Chamilo\CoreBundle\Controller\Api\GetLinksCollectionController; use Chamilo\CoreBundle\Controller\Api\UpdateCLinkAction; use Chamilo\CoreBundle\Controller\Api\UpdatePositionLink; use Chamilo\CoreBundle\Controller\Api\UpdateVisibilityLink; use Chamilo\CoreBundle\Entity\AbstractResource; +use Chamilo\CoreBundle\Entity\Asset; use Chamilo\CoreBundle\Entity\ResourceInterface; use Chamilo\CoreBundle\Entity\ResourceShowCourseResourcesInSessionInterface; use Chamilo\CourseBundle\Repository\CLinkRepository; @@ -36,6 +38,9 @@ operations: [ new Put( controller: UpdateCLinkAction::class, + denormalizationContext: [ + 'groups' => ['link:write'], + ], security: "is_granted('EDIT', object.resourceNode)", validationContext: [ 'groups' => ['media_object_create', 'link:write'], @@ -54,27 +59,10 @@ security: "is_granted('EDIT', object.resourceNode)", deserialize: false ), - new Get(security: "is_granted('VIEW', object.resourceNode)"), - new Get( - uriTemplate: '/links/{iid}/details', - controller: CLinkDetailsController::class, - openapiContext: [ - 'summary' => 'Gets the details of a link, including whether it is on the homepage', - ], - security: "is_granted('VIEW', object.resourceNode)" - ), - new Get( - uriTemplate: '/links/{iid}/check', - controller: CheckCLinkAction::class, - openapiContext: [ - 'summary' => 'Check if a link URL is valid', - ], - security: "is_granted('VIEW', object.resourceNode)" - ), - new Delete(security: "is_granted('DELETE', object.resourceNode)"), new Post( controller: CreateCLinkAction::class, openapiContext: [ + 'summary' => 'Create a new link resource', 'requestBody' => [ 'content' => [ 'application/json' => [ @@ -110,6 +98,49 @@ validationContext: ['groups' => ['Default', 'media_object_create', 'link:write']], deserialize: false ), + new Post( + uriTemplate: '/links/{iid}/upload-image', + controller: CLinkImageController::class, + openapiContext: [ + 'summary' => 'Upload a custom image for a link', + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'customImage' => [ + 'type' => 'string', + 'format' => 'binary', + ], + ], + 'required' => ['customImage'], + ], + ], + ], + ], + ], + security: "is_granted('EDIT', object.resourceNode)", + deserialize: false + ), + new Get(security: "is_granted('VIEW', object.resourceNode)"), + new Get( + uriTemplate: '/links/{iid}/details', + controller: CLinkDetailsController::class, + openapiContext: [ + 'summary' => 'Gets the details of a link, including whether it is on the homepage', + ], + security: "is_granted('VIEW', object.resourceNode)" + ), + new Get( + uriTemplate: '/links/{iid}/check', + controller: CheckCLinkAction::class, + openapiContext: [ + 'summary' => 'Check if a link URL is valid', + ], + security: "is_granted('VIEW', object.resourceNode)" + ), + new Delete(security: "is_granted('DELETE', object.resourceNode)"), new GetCollection( controller: GetLinksCollectionController::class, openapiContext: [ @@ -188,6 +219,11 @@ class CLink extends AbstractResource implements ResourceInterface, ResourceShowC #[Groups(['link:read', 'link:browse'])] protected bool $linkVisible = true; + #[Groups(['cshortcut:read'])] + #[ORM\ManyToOne(targetEntity: Asset::class, cascade: ['remove'])] + #[ORM\JoinColumn(name: 'custom_image_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + private ?Asset $customImage = null; + public function __construct() { $this->description = ''; @@ -268,6 +304,17 @@ public function setCategory(?CLinkCategory $category): self return $this; } + public function getCustomImage(): ?Asset + { + return $this->customImage; + } + + public function setCustomImage(?Asset $customImage): self + { + $this->customImage = $customImage; + return $this; + } + public function toggleVisibility(): void { $this->linkVisible = !$this->getFirstResourceLink()->getVisibility(); diff --git a/src/CourseBundle/Entity/CShortcut.php b/src/CourseBundle/Entity/CShortcut.php index 9d0bb721c9e..2723e369091 100644 --- a/src/CourseBundle/Entity/CShortcut.php +++ b/src/CourseBundle/Entity/CShortcut.php @@ -42,6 +42,10 @@ class CShortcut extends AbstractResource implements ResourceInterface, Stringabl #[Groups(['cshortcut:read'])] protected string $type; + #[Groups(['cshortcut:read'])] + private ?string $customImageUrl = null; + + public function __toString(): string { return $this->getTitle(); @@ -94,6 +98,18 @@ public function setShortCutNode(ResourceNode $shortCutNode): self return $this; } + public function getCustomImageUrl(): ?string + { + return $this->customImageUrl; + } + + public function setCustomImageUrl(?string $customImageUrl): self + { + $this->customImageUrl = $customImageUrl; + + return $this; + } + public function getId(): int { return $this->id;