Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Course: Add custom image support for course links in homepage tools - refs #2863 #6039

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/vue/components/course/ShortCutList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
>
<img
:alt="shortcut.title"
:src="`/img/tools/${shortcut.type}.png`"
:src="shortcut.customImageUrl || `/img/tools/${shortcut.type}.png`"
class="course-tool__icon"
/>
</BaseAppLink>
Expand Down
65 changes: 64 additions & 1 deletion assets/vue/components/links/LinkForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,38 @@
option-value="value"
/>

<div v-if="formData.showOnHomepage">
<div
v-if="formData.customImageUrl"
class="mb-4"
>
<p class="text-gray-600">{{ t("Current Image") }}</p>
<img
:src="formData.customImageUrl"
alt="Custom Image"
class="w-24 h-24 object-cover"
/>
<BaseButton
:label="t('Remove Current Image')"
icon="trash"
type="danger"
@click="removeCurrentImage"
/>
</div>

<BaseFileUpload
id="custom-image"
:label="t('Custom Image')"
accept="image"
size="small"
@file-selected="selectedFile = $event"
/>
<p class="text-gray-600">
{{ t("This image will serve as the icon for the link displayed as a tool on the course homepage.") }}
</p>
<p class="text-gray-600">{{ t("Image must be 120x120 pixels.") }}</p>
</div>

<LayoutFormButtons>
<BaseButton
:label="t('Back')"
Expand Down Expand Up @@ -76,12 +108,14 @@ import BaseTextArea from "../basecomponents/BaseTextArea.vue"
import BaseSelect from "../basecomponents/BaseSelect.vue"
import { useNotification } from "../../composables/notification"
import LayoutFormButtons from "../layout/LayoutFormButtons.vue"
import BaseFileUpload from "../basecomponents/BaseFileUpload.vue"

const notification = useNotification()
const { t } = useI18n()
const { cid, sid } = useCidReq()
const router = useRouter()
const route = useRoute()
const selectedFile = ref(null)

const props = defineProps({
linkId: {
Expand Down Expand Up @@ -111,6 +145,9 @@ const formData = reactive({
category: null,
showOnHomepage: false,
target: "_blank",
customImage: null,
customImageUrl: null,
removeImage: false,
})
const rules = {
url: { required, url },
Expand Down Expand Up @@ -146,6 +183,11 @@ const fetchLink = async () => {
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())
}
Expand All @@ -155,6 +197,11 @@ const fetchLink = async () => {
}
}

const removeCurrentImage = () => {
formData.customImageUrl = null
formData.removeImage = true
}

const submitForm = async () => {
v$.value.$touch()

Expand All @@ -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"))
Expand All @@ -192,6 +254,7 @@ const submitForm = async () => {
})
} catch (error) {
console.error("Error updating link:", error)
notification.showErrorNotification(t("Error saving the link"))
}
}
</script>
14 changes: 14 additions & 0 deletions assets/vue/services/linkService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
10 changes: 8 additions & 2 deletions assets/vue/views/course/CourseHome.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion src/CoreBundle/Controller/Api/CLinkDetailsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

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;
use Symfony\Component\HttpFoundation\Response;

class CLinkDetailsController extends AbstractController
{
public function __invoke(CLink $link, CShortcutRepository $shortcutRepository): Response
public function __invoke(CLink $link, CShortcutRepository $shortcutRepository, AssetRepository $assetRepository): Response
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function doc comment

{
$shortcut = $shortcutRepository->getShortcutFromResource($link);
$isOnHomepage = null !== $shortcut;
Expand Down Expand Up @@ -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);
}
}
126 changes: 126 additions & 0 deletions src/CoreBundle/Controller/Api/CLinkImageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a single space around assignment operators


/* For licensing terms, see /license.txt */

namespace Chamilo\CoreBundle\Controller\Api;

use Chamilo\CoreBundle\Entity\Asset;
use Chamilo\CourseBundle\Entity\CLink;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class CLinkImageController
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing class doc comment

{
private EntityManagerInterface $entityManager;

public function __construct(EntityManagerInterface $entityManager)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function doc comment

{
$this->entityManager = $entityManager;
}

public function __invoke(CLink $link, Request $request): Response
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function doc comment

{
$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;
}
}
20 changes: 20 additions & 0 deletions src/CoreBundle/Controller/CourseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -133,6 +136,7 @@ public function indexJson(
Request $request,
CShortcutRepository $shortcutRepository,
EntityManagerInterface $em,
AssetRepository $assetRepository
): Response {
$requestData = json_decode($request->getContent(), true);
// Sort behaviour
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/CoreBundle/Entity/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand Down
Loading
Loading