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

Merged
merged 2 commits into from
Mar 22, 2025
Merged
Changes from 1 commit
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
@@ -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>
65 changes: 64 additions & 1 deletion assets/vue/components/links/LinkForm.vue
Original file line number Diff line number Diff line change
@@ -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')"
@@ -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: {
@@ -111,6 +145,9 @@ const formData = reactive({
category: null,
showOnHomepage: false,
target: "_blank",
customImage: null,
customImageUrl: null,
removeImage: false,
})
const rules = {
url: { required, url },
@@ -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())
}
@@ -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"))
}
}
</script>
14 changes: 14 additions & 0 deletions assets/vue/services/linkService.js
Original file line number Diff line number Diff line change
@@ -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
*/
10 changes: 8 additions & 2 deletions assets/vue/views/course/CourseHome.vue
Original file line number Diff line number Diff line change
@@ -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)

9 changes: 8 additions & 1 deletion src/CoreBundle/Controller/Api/CLinkDetailsController.php
Original file line number Diff line number Diff line change
@@ -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;
@@ -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);

/* 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
@@ -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,
1 change: 1 addition & 0 deletions src/CoreBundle/Entity/Asset.php
Original file line number Diff line number Diff line change
@@ -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')]
36 changes: 36 additions & 0 deletions src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Chamilo\CoreBundle\Migrations\Schema\V200;

use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;

final class Version20250118000100 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Add custom_image_id field to c_link table and set up the foreign key to asset table.';
}

public function up(Schema $schema): void
{
// Add the new column and foreign key
$this->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
');
}
}
83 changes: 65 additions & 18 deletions src/CourseBundle/Entity/CLink.php
Original file line number Diff line number Diff line change
@@ -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();
16 changes: 16 additions & 0 deletions src/CourseBundle/Entity/CShortcut.php
Original file line number Diff line number Diff line change
@@ -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;