diff --git a/assets/vue/app/domain/conversion/application/store/conversionStore.js b/assets/vue/app/domain/conversion/application/store/conversionStore.js new file mode 100644 index 0000000..3175a13 --- /dev/null +++ b/assets/vue/app/domain/conversion/application/store/conversionStore.js @@ -0,0 +1,240 @@ +import { defineStore } from 'pinia'; +import { ApiConversionRepository } from '../../infrastructure/api/apiConversionRepository'; + +const conversionRepository = new ApiConversionRepository(); + +export const useConversionStore = defineStore('conversion', { + state: () => ({ + // État de conversion + isConverting: false, + conversionProgress: 0, + conversionError: null, + conversionSuccess: false, + + // Fichier en cours de traitement + currentFile: null, + convertedFile: null, + + // Historique des conversions (optionnel) + conversionHistory: [], + + // État de l'interface + isDragOver: false, + showSuccessMessage: false, + }), + + getters: { + /** + * Indique si une conversion est en cours + */ + isProcessing: (state) => state.isConverting, + + /** + * Indique si un fichier est sélectionné + */ + hasSelectedFile: (state) => state.currentFile !== null, + + /** + * Indique si une conversion a réussi + */ + hasSucceeded: (state) => state.conversionSuccess && state.convertedFile !== null, + + /** + * Indique si une erreur est présente + */ + hasError: (state) => state.conversionError !== null, + + /** + * Obtient le nom du fichier actuel + */ + currentFileName: (state) => state.currentFile?.name || '', + + /** + * Obtient la taille formatée du fichier actuel + */ + currentFileSize: (state) => { + if (!state.currentFile) return ''; + + const bytes = state.currentFile.size; + const sizes = ['octets', 'Ko', 'Mo', 'Go']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }, + + /** + * Obtient le nombre de conversions réussies + */ + conversionCount: (state) => state.conversionHistory.length, + }, + + actions: { + /** + * Sélectionne un fichier pour la conversion + * @param {File} file - Le fichier sélectionné + */ + selectFile(file) { + // Validation du fichier + const validation = conversionRepository.validateFile(file); + + if (!validation.isValid) { + this.setError(validation.error); + return false; + } + + // Réinitialisation de l'état + this.clearError(); + this.conversionSuccess = false; + this.convertedFile = null; + this.showSuccessMessage = false; + + // Stockage du fichier + this.currentFile = file; + + return true; + }, + + /** + * Lance la conversion du fichier sélectionné + */ + async convertCurrentFile() { + if (!this.currentFile) { + this.setError('Aucun fichier sélectionné'); + return false; + } + + try { + this.isConverting = true; + this.conversionProgress = 0; + this.clearError(); + + // Simulation du progrès (l'API ne fournit pas de progrès en temps réel) + const progressInterval = setInterval(() => { + if (this.conversionProgress < 90) { + this.conversionProgress += Math.random() * 10; + } + }, 100); + + // Appel à l'API de conversion + const convertedFileBlob = await conversionRepository.convertFile(this.currentFile); + + // Nettoyage de l'interval de progrès + clearInterval(progressInterval); + this.conversionProgress = 100; + + // Stockage du fichier converti + this.convertedFile = convertedFileBlob; + this.conversionSuccess = true; + this.showSuccessMessage = true; + + // Ajout à l'historique + this.addToHistory({ + originalName: this.currentFile.name, + originalSize: this.currentFile.size, + convertedSize: convertedFileBlob.size, + timestamp: new Date().toISOString(), + }); + + return true; + } catch (error) { + this.setError(error.message || 'Erreur lors de la conversion'); + return false; + } finally { + this.isConverting = false; + this.conversionProgress = 0; + } + }, + + /** + * Télécharge le fichier converti + */ + downloadConvertedFile() { + if (!this.convertedFile || !this.currentFile) { + this.setError('Aucun fichier converti disponible'); + return; + } + + try { + conversionRepository.downloadConvertedFile( + this.convertedFile, + this.currentFile.name + ); + } catch (error) { + this.setError(error.message || 'Erreur lors du téléchargement'); + } + }, + + /** + * Réinitialise l'état de conversion + */ + resetConversion() { + this.currentFile = null; + this.convertedFile = null; + this.conversionSuccess = false; + this.showSuccessMessage = false; + this.conversionProgress = 0; + this.clearError(); + }, + + /** + * Définit une erreur + * @param {string} message - Message d'erreur + */ + setError(message) { + this.conversionError = message; + this.conversionSuccess = false; + this.showSuccessMessage = false; + }, + + /** + * Efface l'erreur actuelle + */ + clearError() { + this.conversionError = null; + }, + + /** + * Cache le message de succès + */ + hideSuccessMessage() { + this.showSuccessMessage = false; + }, + + /** + * Gère l'état du drag and drop + * @param {boolean} isDragOver - Indique si un fichier est survolé + */ + setDragOver(isDragOver) { + this.isDragOver = isDragOver; + }, + + /** + * Ajoute une conversion à l'historique + * @param {Object} conversionData - Données de la conversion + */ + addToHistory(conversionData) { + this.conversionHistory.unshift(conversionData); + + // Limiter l'historique à 10 éléments + if (this.conversionHistory.length > 10) { + this.conversionHistory = this.conversionHistory.slice(0, 10); + } + }, + + /** + * Efface l'historique des conversions + */ + clearHistory() { + this.conversionHistory = []; + }, + + /** + * Valide un fichier sans le sélectionner + * @param {File} file - Le fichier à valider + * @returns {Object} - Résultat de la validation + */ + validateFile(file) { + return conversionRepository.validateFile(file); + } + } +}); diff --git a/assets/vue/app/domain/conversion/infrastructure/api/apiConversionRepository.js b/assets/vue/app/domain/conversion/infrastructure/api/apiConversionRepository.js new file mode 100644 index 0000000..67574e4 --- /dev/null +++ b/assets/vue/app/domain/conversion/infrastructure/api/apiConversionRepository.js @@ -0,0 +1,133 @@ +export class ApiConversionRepository { + /** + * Convertit un fichier CBR/CBZ en CBZ + * @param {File} file - Le fichier à convertir + * @returns {Promise} - Le fichier converti + */ + async convertFile(file) { + try { + // Validation du fichier + if (!file) { + throw new Error('Aucun fichier fourni'); + } + + // Validation de la taille (150MB max selon l'API) + const maxSize = 150 * 1024 * 1024; // 150MB en bytes + if (file.size > maxSize) { + throw new Error('Le fichier est trop volumineux (max 150MB)'); + } + + // Validation du type de fichier + const allowedTypes = ['.cbr', '.cbz']; + const fileName = file.name.toLowerCase(); + const isValidType = allowedTypes.some(type => fileName.endsWith(type)); + + if (!isValidType) { + throw new Error('Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés'); + } + + // Création du FormData pour l'envoi multipart + const formData = new FormData(); + formData.append('file', file); + + // Appel à l'API + const response = await fetch('/api/conversions/convert', { + method: 'POST', + body: formData, + // On ne définit pas Content-Type pour laisser le navigateur gérer multipart/form-data + }); + + if (!response.ok) { + // Gestion des erreurs HTTP + let errorMessage = 'Erreur lors de la conversion'; + + try { + const errorData = await response.json(); + errorMessage = errorData.message || errorData.detail || errorMessage; + } catch { + // Si la réponse n'est pas du JSON, on utilise le status text + errorMessage = response.statusText || errorMessage; + } + + throw new Error(errorMessage); + } + + // Récupération du fichier converti + const convertedFile = await response.blob(); + + // Vérification que le fichier n'est pas vide + if (convertedFile.size === 0) { + throw new Error('Le fichier converti est vide'); + } + + return convertedFile; + } catch (error) { + console.error('Erreur lors de la conversion:', error); + throw error; + } + } + + /** + * Télécharge le fichier converti + * @param {Blob} fileBlob - Le fichier à télécharger + * @param {string} originalFileName - Nom original du fichier + */ + downloadConvertedFile(fileBlob, originalFileName) { + try { + // Génération du nom de fichier de sortie + const baseName = originalFileName.replace(/\.(cbr|cbz)$/i, ''); + const outputFileName = `${baseName}.cbz`; + + // Création d'un lien de téléchargement + const url = URL.createObjectURL(fileBlob); + const link = document.createElement('a'); + link.href = url; + link.download = outputFileName; + + // Déclenchement du téléchargement + document.body.appendChild(link); + link.click(); + + // Nettoyage + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Erreur lors du téléchargement:', error); + throw new Error('Impossible de télécharger le fichier converti'); + } + } + + /** + * Valide si un fichier peut être converti + * @param {File} file - Le fichier à valider + * @returns {Object} - Résultat de la validation {isValid: boolean, error?: string} + */ + validateFile(file) { + if (!file) { + return { isValid: false, error: 'Aucun fichier sélectionné' }; + } + + // Vérification de la taille + const maxSize = 150 * 1024 * 1024; // 150MB + if (file.size > maxSize) { + return { + isValid: false, + error: 'Le fichier est trop volumineux (maximum 150MB)' + }; + } + + // Vérification du type + const allowedTypes = ['.cbr', '.cbz']; + const fileName = file.name.toLowerCase(); + const isValidType = allowedTypes.some(type => fileName.endsWith(type)); + + if (!isValidType) { + return { + isValid: false, + error: 'Type de fichier non supporté. Seuls les fichiers .cbr et .cbz sont acceptés' + }; + } + + return { isValid: true }; + } +} diff --git a/assets/vue/app/domain/conversion/presentation/components/ConversionProgress.vue b/assets/vue/app/domain/conversion/presentation/components/ConversionProgress.vue new file mode 100644 index 0000000..c2d628c --- /dev/null +++ b/assets/vue/app/domain/conversion/presentation/components/ConversionProgress.vue @@ -0,0 +1,247 @@ + + + diff --git a/assets/vue/app/domain/conversion/presentation/components/FileUploadArea.vue b/assets/vue/app/domain/conversion/presentation/components/FileUploadArea.vue new file mode 100644 index 0000000..b4fca61 --- /dev/null +++ b/assets/vue/app/domain/conversion/presentation/components/FileUploadArea.vue @@ -0,0 +1,214 @@ + + + diff --git a/assets/vue/app/domain/conversion/presentation/pages/ConversionPage.vue b/assets/vue/app/domain/conversion/presentation/pages/ConversionPage.vue new file mode 100644 index 0000000..b43fa4f --- /dev/null +++ b/assets/vue/app/domain/conversion/presentation/pages/ConversionPage.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index f634a82..bf24be3 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router'; import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; +import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue'; import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; @@ -71,8 +72,7 @@ const routes = [ { path: '/convert', name: 'convert', - component: PlaceholderComponent, - props: { title: 'Convertir CBR en CBZ' } + component: ConversionPage }, { path: '/calendar', diff --git a/phparkitect.php b/phparkitect.php index b43533f..f111c7c 100644 --- a/phparkitect.php +++ b/phparkitect.php @@ -12,7 +12,7 @@ use Arkitect\Rules\Rule; return static function (Config $config): void { $domainClassSet = ClassSet::fromDir(__DIR__ . '/src/Domain'); - $businessDomains = ['Manga', 'Reader', 'Scraping']; + $businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion']; // Classes PHP standards et utilitaires $standardExceptions = [ @@ -64,7 +64,7 @@ return static function (Config $config): void { ->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application")) ->should(new NotHaveDependencyOutsideNamespace( "App\Domain\\$domain", - array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Contract']) + array_merge($standardExceptions, $externalDependencies, ['App\Domain\Shared\Domain\Contract']) )) ->because("la couche Application de $domain ne peut dépendre que de son propre domaine, des contrats partagés et des dépendances autorisées"); diff --git a/public/api-docs.json b/public/api-docs.json index eba4a82..7cf1d5f 100644 --- a/public/api-docs.json +++ b/public/api-docs.json @@ -205,6 +205,22 @@ "_embedded" ] } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentSource" + } + } + }, + "application/x-cbz": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentSource" + } + } } } } @@ -258,6 +274,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "links": {} @@ -294,6 +320,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "required": true @@ -331,6 +367,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } } }, @@ -374,6 +420,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "links": {} @@ -410,6 +466,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "required": true @@ -447,6 +513,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } } }, @@ -502,6 +578,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "links": {} @@ -556,6 +642,16 @@ "schema": { "$ref": "#/components/schemas/ContentSource.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/ContentSource" + } } }, "required": true @@ -564,6 +660,53 @@ }, "parameters": [] }, + "/api/conversions/convert": { + "post": { + "operationId": "api_conversionsconvert_post", + "tags": [ + "Conversion" + ], + "responses": { + "200": { + "description": "File converted successfully", + "content": { + "application/x-cbz": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "summary": "Convert comic book file to CBZ", + "description": "Converts a CBR or CBZ file to CBZ format and returns the converted file for download", + "parameters": [], + "requestBody": { + "description": "", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "Comic book file to convert (CBR, CBZ, max 150MB)" + } + } + } + } + }, + "required": false + }, + "deprecated": false + }, + "parameters": [] + }, "/api/jobs": { "get": { "operationId": "api_jobs_get_collection", @@ -757,6 +900,22 @@ "_embedded" ] } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + }, + "application/x-cbz": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } } } } @@ -1056,6 +1215,12 @@ }, "application/hal+json": { "schema": {} + }, + "multipart/form-data": { + "schema": {} + }, + "application/x-cbz": { + "schema": {} } } }, @@ -1114,6 +1279,16 @@ "schema": { "$ref": "#/components/schemas/Mangadex.MangaSearchCollection.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Mangadex.MangaSearchCollection" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Mangadex.MangaSearchCollection" + } } } }, @@ -1336,6 +1511,22 @@ "_embedded" ] } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Manga.MangaCollection" + } + } + }, + "application/x-cbz": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Manga.MangaCollection" + } + } } } } @@ -1392,6 +1583,16 @@ "schema": { "$ref": "#/components/schemas/Manga.MangaDetail.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga.MangaDetail" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga.MangaDetail" + } } } }, @@ -1450,6 +1651,16 @@ "schema": { "$ref": "#/components/schemas/Manga.MangaDetail.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga.MangaDetail" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga.MangaDetail" + } } } }, @@ -1508,6 +1719,16 @@ "schema": { "$ref": "#/components/schemas/Manga.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga" + } } }, "links": {} @@ -1544,6 +1765,16 @@ "schema": { "$ref": "#/components/schemas/Manga.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga" + } } }, "required": true @@ -1581,6 +1812,16 @@ "schema": { "$ref": "#/components/schemas/Mangadex.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Mangadex" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Mangadex" + } } }, "links": {} @@ -1733,6 +1974,16 @@ "schema": { "$ref": "#/components/schemas/Chapters.ChapterCollection.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Chapters.ChapterCollection" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Chapters.ChapterCollection" + } } } }, @@ -1840,6 +2091,16 @@ "schema": { "$ref": "#/components/schemas/Manga.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga" + } } }, "links": {} @@ -1894,6 +2155,16 @@ "schema": { "$ref": "#/components/schemas/Manga.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Manga" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Manga" + } } }, "required": true @@ -2022,6 +2293,16 @@ "schema": { "$ref": "#/components/schemas/Scraping.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } } }, "links": {} @@ -2101,6 +2382,12 @@ }, "application/hal+json": { "schema": {} + }, + "multipart/form-data": { + "schema": {} + }, + "application/x-cbz": { + "schema": {} } } }, @@ -2461,6 +2748,16 @@ "schema": { "$ref": "#/components/schemas/Scraping.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } } }, "links": {} @@ -2497,6 +2794,16 @@ "schema": { "$ref": "#/components/schemas/Scraping.jsonhal" } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } + }, + "application/x-cbz": { + "schema": { + "$ref": "#/components/schemas/Scraping" + } } }, "required": true @@ -3297,6 +3604,154 @@ } } }, + "Conversion": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "file": { + "type": [ + "string", + "null" + ], + "format": "binary" + }, + "fileName": { + "type": [ + "string", + "null" + ] + }, + "fileContent": {}, + "filename": { + "type": [ + "string", + "null" + ] + }, + "originalConvertedFilePath": { + "type": [ + "string", + "null" + ] + } + } + }, + "Conversion.jsonhal": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + } + } + }, + "file": { + "type": [ + "string", + "null" + ], + "format": "binary" + }, + "fileName": { + "type": [ + "string", + "null" + ] + }, + "fileContent": {}, + "filename": { + "type": [ + "string", + "null" + ] + }, + "originalConvertedFilePath": { + "type": [ + "string", + "null" + ] + } + } + }, + "Conversion.jsonld": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "@context": { + "readOnly": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "type": "string", + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ] + } + }, + "required": [ + "@vocab", + "hydra" + ], + "additionalProperties": true + } + ] + }, + "@id": { + "readOnly": true, + "type": "string" + }, + "@type": { + "readOnly": true, + "type": "string" + }, + "file": { + "type": [ + "string", + "null" + ], + "format": "binary" + }, + "fileName": { + "type": [ + "string", + "null" + ] + }, + "fileContent": {}, + "filename": { + "type": [ + "string", + "null" + ] + }, + "originalConvertedFilePath": { + "type": [ + "string", + "null" + ] + } + } + }, "Job": { "type": "object", "description": "Liste des jobs", diff --git a/symfony.lock b/symfony.lock index d0ae49a..0101344 100644 --- a/symfony.lock +++ b/symfony.lock @@ -25,6 +25,15 @@ "config/packages/dama_doctrine_test_bundle.yaml" ] }, + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, "doctrine/doctrine-bundle": { "version": "2.11", "recipe": { @@ -350,6 +359,18 @@ "twig/extra-bundle": { "version": "v3.10.0" }, + "vich/uploader-bundle": { + "version": "2.7", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.13", + "ref": "1b3064c2f6b255c2bc2f56461aaeb76b11e07e36" + }, + "files": [ + "config/packages/vich_uploader.yaml" + ] + }, "zenstruck/foundry": { "version": "1.36", "recipe": {