Skip to content

Commit ca97c4c

Browse files
Course: Add custom image support for course links in homepage tools - refs #2863 (#6039)
Author: @christianbeeznest
1 parent 017cdc1 commit ca97c4c

File tree

11 files changed

+359
-23
lines changed

11 files changed

+359
-23
lines changed

assets/vue/components/course/ShortCutList.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
>
77
<img
88
:alt="shortcut.title"
9-
:src="`/img/tools/${shortcut.type}.png`"
9+
:src="shortcut.customImageUrl || `/img/tools/${shortcut.type}.png`"
1010
class="course-tool__icon"
1111
/>
1212
</BaseAppLink>

assets/vue/components/links/LinkForm.vue

+64-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,38 @@
4343
option-value="value"
4444
/>
4545

46+
<div v-if="formData.showOnHomepage">
47+
<div
48+
v-if="formData.customImageUrl"
49+
class="mb-4"
50+
>
51+
<p class="text-gray-600">{{ t("Current icon") }}</p>
52+
<img
53+
:src="formData.customImageUrl"
54+
alt="Custom Image"
55+
class="w-24 h-24 object-cover"
56+
/>
57+
<BaseButton
58+
:label="t('Remove current icon')"
59+
icon="trash"
60+
type="danger"
61+
@click="removeCurrentImage"
62+
/>
63+
</div>
64+
65+
<BaseFileUpload
66+
id="custom-image"
67+
:label="t('Custom icon')"
68+
accept="image"
69+
size="small"
70+
@file-selected="selectedFile = $event"
71+
/>
72+
<p class="text-gray-600">
73+
{{ t("This icon will show for the link displayed as a tool on the course homepage.") }}
74+
</p>
75+
<p class="text-gray-600">{{ t("The icon must be 120x120 pixels.") }}</p>
76+
</div>
77+
4678
<LayoutFormButtons>
4779
<BaseButton
4880
:label="t('Back')"
@@ -76,12 +108,14 @@ import BaseTextArea from "../basecomponents/BaseTextArea.vue"
76108
import BaseSelect from "../basecomponents/BaseSelect.vue"
77109
import { useNotification } from "../../composables/notification"
78110
import LayoutFormButtons from "../layout/LayoutFormButtons.vue"
111+
import BaseFileUpload from "../basecomponents/BaseFileUpload.vue"
79112
80113
const notification = useNotification()
81114
const { t } = useI18n()
82115
const { cid, sid } = useCidReq()
83116
const router = useRouter()
84117
const route = useRoute()
118+
const selectedFile = ref(null)
85119
86120
const props = defineProps({
87121
linkId: {
@@ -111,6 +145,9 @@ const formData = reactive({
111145
category: null,
112146
showOnHomepage: false,
113147
target: "_blank",
148+
customImage: null,
149+
customImageUrl: null,
150+
removeImage: false,
114151
})
115152
const rules = {
116153
url: { required, url },
@@ -146,6 +183,11 @@ const fetchLink = async () => {
146183
formData.target = response.target
147184
formData.parentResourceNodeId = response.parentResourceNodeId
148185
formData.resourceLinkList = response.resourceLinkList
186+
187+
if (response.customImageUrl) {
188+
formData.customImageUrl = response.customImageUrl
189+
}
190+
149191
if (response.category) {
150192
formData.category = parseInt(response.category["@id"].split("/").pop())
151193
}
@@ -155,6 +197,11 @@ const fetchLink = async () => {
155197
}
156198
}
157199
200+
const removeCurrentImage = () => {
201+
formData.customImageUrl = null
202+
formData.removeImage = true
203+
}
204+
158205
const submitForm = async () => {
159206
v$.value.$touch()
160207
@@ -180,8 +227,23 @@ const submitForm = async () => {
180227
try {
181228
if (props.linkId) {
182229
await linkService.updateLink(props.linkId, postData)
230+
231+
const formDataImage = new FormData()
232+
formDataImage.append("removeImage", formData.removeImage ? "true" : "false")
233+
234+
if (selectedFile.value instanceof File) {
235+
formDataImage.append("customImage", selectedFile.value)
236+
}
237+
238+
await linkService.uploadImage(props.linkId, formDataImage)
183239
} else {
184-
await linkService.createLink(postData)
240+
const newLink = await linkService.createLink(postData)
241+
242+
if (selectedFile.value instanceof File) {
243+
const formDataImage = new FormData()
244+
formDataImage.append("customImage", selectedFile.value)
245+
await linkService.uploadImage(newLink.iid, formDataImage)
246+
}
185247
}
186248
187249
notification.showSuccessNotification(t("Link saved"))
@@ -192,6 +254,7 @@ const submitForm = async () => {
192254
})
193255
} catch (error) {
194256
console.error("Error updating link:", error)
257+
notification.showErrorNotification(t("Error saving the link"))
195258
}
196259
}
197260
</script>

assets/vue/services/linkService.js

+14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import axios from "axios"
33
import baseService from "./baseService"
44

55
export default {
6+
/**
7+
* @param {Number|String} linkId
8+
* @param {FormData} imageData
9+
*/
10+
uploadImage: async (linkId, imageData) => {
11+
const endpoint = `${ENTRYPOINT}links/${linkId}/upload-image`
12+
const response = await axios.post(endpoint, imageData, {
13+
headers: {
14+
"Content-Type": "multipart/form-data",
15+
},
16+
})
17+
return response.data
18+
},
19+
620
/**
721
* @param {Object} params
822
*/

assets/vue/views/course/CourseHome.vue

+8-2
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,15 @@ courseService.loadCTools(course.value.id, session.value?.id).then((cTools) => {
315315
courseService
316316
.loadTools(course.value.id, session.value?.id)
317317
.then((data) => {
318-
shortcuts.value = data.shortcuts
318+
shortcuts.value = data.shortcuts.map((shortcut) => {
319+
return {
320+
...shortcut,
321+
customImageUrl: shortcut.customImageUrl || null,
322+
}
323+
})
319324
})
320-
.catch((error) => console.log(error))
325+
.catch((error) => console.error(error))
326+
321327
322328
const courseTMenu = ref(null)
323329

src/CoreBundle/Controller/Api/CLinkDetailsController.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66

77
namespace Chamilo\CoreBundle\Controller\Api;
88

9+
use Chamilo\CoreBundle\Repository\AssetRepository;
910
use Chamilo\CourseBundle\Entity\CLink;
1011
use Chamilo\CourseBundle\Repository\CShortcutRepository;
1112
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1213
use Symfony\Component\HttpFoundation\Response;
1314

1415
class CLinkDetailsController extends AbstractController
1516
{
16-
public function __invoke(CLink $link, CShortcutRepository $shortcutRepository): Response
17+
public function __invoke(CLink $link, CShortcutRepository $shortcutRepository, AssetRepository $assetRepository): Response
1718
{
1819
$shortcut = $shortcutRepository->getShortcutFromResource($link);
1920
$isOnHomepage = null !== $shortcut;
@@ -45,6 +46,12 @@ public function __invoke(CLink $link, CShortcutRepository $shortcutRepository):
4546
'category' => $link->getCategory()?->getIid(),
4647
];
4748

49+
if (null !== $link->getCustomImage()) {
50+
$details['customImageUrl'] = $assetRepository->getAssetUrl($link->getCustomImage());
51+
} else {
52+
$details['customImageUrl'] = null;
53+
}
54+
4855
return $this->json($details, Response::HTTP_OK);
4956
}
5057
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\Controller\Api;
8+
9+
use Chamilo\CoreBundle\Entity\Asset;
10+
use Chamilo\CourseBundle\Entity\CLink;
11+
use Doctrine\ORM\EntityManagerInterface;
12+
use Symfony\Component\HttpFoundation\File\File;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Response;
15+
16+
class CLinkImageController
17+
{
18+
private EntityManagerInterface $entityManager;
19+
20+
public function __construct(EntityManagerInterface $entityManager)
21+
{
22+
$this->entityManager = $entityManager;
23+
}
24+
25+
public function __invoke(CLink $link, Request $request): Response
26+
{
27+
$removeImage = $request->request->getBoolean('removeImage', false);
28+
$file = $request->files->get('customImage');
29+
30+
if ($removeImage) {
31+
if ($link->getCustomImage()) {
32+
$this->entityManager->remove($link->getCustomImage());
33+
$link->setCustomImage(null);
34+
$this->entityManager->persist($link);
35+
$this->entityManager->flush();
36+
37+
if (!$file) {
38+
return new Response('Image removed successfully', Response::HTTP_OK);
39+
}
40+
}
41+
}
42+
43+
if (!$file || !$file->isValid()) {
44+
return new Response('Invalid or missing file', Response::HTTP_BAD_REQUEST);
45+
}
46+
47+
try {
48+
$asset = new Asset();
49+
$asset->setFile($file)
50+
->setCategory(Asset::LINK)
51+
->setTitle($file->getClientOriginalName());
52+
53+
$this->entityManager->persist($asset);
54+
$this->entityManager->flush();
55+
56+
$uploadedFilePath = $file->getPathname();
57+
58+
$croppedFilePath = $this->cropImage($uploadedFilePath);
59+
60+
if (!file_exists($croppedFilePath)) {
61+
@unlink($uploadedFilePath);
62+
return new Response('Error creating cropped image', Response::HTTP_INTERNAL_SERVER_ERROR);
63+
}
64+
65+
$asset->setFile(new File($croppedFilePath));
66+
$this->entityManager->persist($asset);
67+
$this->entityManager->flush();
68+
69+
$link->setCustomImage($asset);
70+
$this->entityManager->persist($link);
71+
$this->entityManager->flush();
72+
73+
return new Response('Image uploaded and linked successfully', Response::HTTP_OK);
74+
75+
} catch (\Exception $e) {
76+
return new Response('Error processing image: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
77+
}
78+
}
79+
80+
private function cropImage(string $filePath): string
81+
{
82+
[$originalWidth, $originalHeight, $imageType] = getimagesize($filePath);
83+
84+
if (!$originalWidth || !$originalHeight) {
85+
throw new \RuntimeException('Invalid image file');
86+
}
87+
88+
switch ($imageType) {
89+
case IMAGETYPE_JPEG:
90+
$sourceImage = imagecreatefromjpeg($filePath);
91+
break;
92+
case IMAGETYPE_PNG:
93+
$sourceImage = imagecreatefrompng($filePath);
94+
break;
95+
case IMAGETYPE_GIF:
96+
$sourceImage = imagecreatefromgif($filePath);
97+
break;
98+
default:
99+
throw new \RuntimeException('Unsupported image type');
100+
}
101+
102+
$croppedImage = imagecreatetruecolor(120, 120);
103+
104+
$cropWidth = min($originalWidth, $originalHeight);
105+
$cropHeight = $cropWidth;
106+
$srcX = (int) (($originalWidth - $cropWidth) / 2);
107+
$srcY = (int) (($originalHeight - $cropHeight) / 2);
108+
109+
imagecopyresampled(
110+
$croppedImage,
111+
$sourceImage,
112+
0, 0,
113+
$srcX, $srcY,
114+
$cropWidth, $cropHeight,
115+
120, 120
116+
);
117+
118+
$croppedFilePath = sys_get_temp_dir() . '/' . uniqid('cropped_', true) . '.png';
119+
imagepng($croppedImage, $croppedFilePath);
120+
121+
imagedestroy($sourceImage);
122+
imagedestroy($croppedImage);
123+
124+
return $croppedFilePath;
125+
}
126+
}

src/CoreBundle/Controller/CourseController.php

+20
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Chamilo\CoreBundle\Entity\Tool;
1616
use Chamilo\CoreBundle\Entity\User;
1717
use Chamilo\CoreBundle\Framework\Container;
18+
use Chamilo\CoreBundle\Repository\AssetRepository;
1819
use Chamilo\CoreBundle\Repository\CourseCategoryRepository;
1920
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository;
2021
use Chamilo\CoreBundle\Repository\LanguageRepository;
@@ -30,6 +31,8 @@
3031
use Chamilo\CoreBundle\Tool\ToolChain;
3132
use Chamilo\CourseBundle\Controller\ToolBaseController;
3233
use Chamilo\CourseBundle\Entity\CCourseDescription;
34+
use Chamilo\CourseBundle\Entity\CLink;
35+
use Chamilo\CourseBundle\Entity\CShortcut;
3336
use Chamilo\CourseBundle\Entity\CTool;
3437
use Chamilo\CourseBundle\Entity\CToolIntro;
3538
use Chamilo\CourseBundle\Repository\CCourseDescriptionRepository;
@@ -133,6 +136,7 @@ public function indexJson(
133136
Request $request,
134137
CShortcutRepository $shortcutRepository,
135138
EntityManagerInterface $em,
139+
AssetRepository $assetRepository
136140
): Response {
137141
$requestData = json_decode($request->getContent(), true);
138142
// Sort behaviour
@@ -214,6 +218,22 @@ public function indexJson(
214218
if (null !== $user) {
215219
$shortcutQuery = $shortcutRepository->getResources($course->getResourceNode());
216220
$shortcuts = $shortcutQuery->getQuery()->getResult();
221+
222+
/* @var CShortcut $shortcut */
223+
foreach ($shortcuts as $shortcut) {
224+
$resourceNode = $shortcut->getShortCutNode();
225+
$cLink = $em->getRepository(CLink::class)->findOneBy(['resourceNode' => $resourceNode]);
226+
227+
if ($cLink) {
228+
$shortcut->setCustomImageUrl(
229+
$cLink->getCustomImage()
230+
? $assetRepository->getAssetUrl($cLink->getCustomImage())
231+
: null
232+
);
233+
} else {
234+
$shortcut->setCustomImageUrl(null);
235+
}
236+
}
217237
}
218238
$responseData = [
219239
'shortcuts' => $shortcuts,

src/CoreBundle/Entity/Asset.php

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class Asset implements Stringable
3939
public const SYSTEM_TEMPLATE = 'system_template';
4040
public const TEMPLATE = 'template';
4141
public const SESSION = 'session';
42+
public const LINK = 'link';
4243

4344
#[ORM\Id]
4445
#[ORM\Column(type: 'uuid')]

0 commit comments

Comments
 (0)