pull/6039/merge
christianbeeznest 8 months ago committed by GitHub
commit acaf033368
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      assets/vue/components/course/ShortCutList.vue
  2. 65
      assets/vue/components/links/LinkForm.vue
  3. 14
      assets/vue/services/linkService.js
  4. 10
      assets/vue/views/course/CourseHome.vue
  5. 9
      src/CoreBundle/Controller/Api/CLinkDetailsController.php
  6. 126
      src/CoreBundle/Controller/Api/CLinkImageController.php
  7. 20
      src/CoreBundle/Controller/CourseController.php
  8. 1
      src/CoreBundle/Entity/Asset.php
  9. 36
      src/CoreBundle/Migrations/Schema/V200/Version20250118000100.php
  10. 83
      src/CourseBundle/Entity/CLink.php
  11. 16
      src/CourseBundle/Entity/CShortcut.php

@ -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>

@ -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>

@ -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
*/

@ -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)

@ -6,6 +6,7 @@ declare(strict_types=1);
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 @@ 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
{
$shortcut = $shortcutRepository->getShortcutFromResource($link);
$isOnHomepage = null !== $shortcut;
@ -45,6 +46,12 @@ class CLinkDetailsController extends AbstractController
'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);
}
}

@ -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
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->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;
}
}

@ -15,6 +15,7 @@ use Chamilo\CoreBundle\Entity\Tag;
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\Settings\SettingsManager;
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 @@ class CourseController extends ToolBaseController
Request $request,
CShortcutRepository $shortcutRepository,
EntityManagerInterface $em,
AssetRepository $assetRepository
): Response {
$requestData = json_decode($request->getContent(), true);
// Sort behaviour
@ -214,6 +218,22 @@ class CourseController extends ToolBaseController
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,

@ -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')]

@ -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
');
}
}

@ -17,12 +17,14 @@ use ApiPlatform\Metadata\Post;
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 @@ use Symfony\Component\Validator\Constraints as Assert;
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 @@ use Symfony\Component\Validator\Constraints as Assert;
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 @@ use Symfony\Component\Validator\Constraints as Assert;
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 @@ class CLink extends AbstractResource implements ResourceInterface, ResourceShowC
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();

@ -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 @@ class CShortcut extends AbstractResource implements ResourceInterface, Stringabl
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;

Loading…
Cancel
Save