Files
pc_builder/inertia/pages/widgets/SimpleGallery.vue
T
Kevin L'Hereec 94f89be605 chore: harden config, strengthen frontend typing, fix UTF-8 mojibake
Security config (3.1):
- config/session.ts: cookie.secure now follows app.inProduction instead of
  being hardcoded to false
- config/shield.ts: enable CSP with baseline directives compatible with
  Vite dev server and Inertia (self + @viteDevUrl/@viteHmrUrl)

Frontend typing (3.2):
- Replace 'any' props with concrete types (Computer, Component, ComponentType,
  ComputerState) across computers/*.vue and components/*.vue, using the
  existing inertia/pages/types/UITypes.ts definitions
- Fix UITypes.ts: export ComponentType/ComputerState, switch from interface
  to type (interfaces are never assignable to Inertia's ComponentProps index
  signature), drop the stale componentTypeId/componentStateId fields that
  didn't match the actual Lucid schema (real field is typeId, no component
  state exists)
- Add an asPageProp() boundary cast in the two controllers: Lucid's
  serialize() returns a loosely typed ModelObject that isn't structurally
  assignable to the concrete page prop shapes (and the inertia/** tree is
  excluded from the backend tsconfig, so real types can't be imported
  directly across that boundary)

UTF-8 mojibake fixes (3.3):
- Repair broken accented characters (├®/├á/├┤ -> é/à/ô, Ôé¼ -> €) left over
  from a bad encoding during the GWT->Inertia migration, across
  ComponentEditor.vue, ComputerComponentsTable.vue, SimpleGallery.vue,
  TableComponent.vue, isAdminMode.ts and the custom directives

Misc cleanup (3.5):
- computer.ts: replace console.warn with the app logger (includes
  computerId) when .cost is accessed without preloading components
2026-07-23 11:10:37 +02:00

299 lines
7.7 KiB
Vue

<template>
<div class="relative w-full" :id="props.galleryId">
<div v-if="editMode" class="grid grid-cols-3 gap-4 w-full min-h-10">
<div
v-for="image in combinedImages"
:key="getKeyForImage(image)"
class="relative cursor-move group"
draggable="true"
@dragstart="(event) => dragStart(event, combinedImages.indexOf(image))"
@dragover.prevent
@dragenter.stop.prevent="handleDragEnter(combinedImages.indexOf(image))"
@drop.stop.prevent="drop"
>
<img :src="getImageSrc(image)" class="w-full h-66 object-cover rounded-lg" />
<button
v-if="editMode"
class="absolute top-2 right-2 bg-white text-gray-800 w-6 h-6 flex items-center justify-center p-0 rounded-full hover:bg-gray-300 transition-opacity opacity-0 group-hover:opacity-100"
@click.stop.prevent="removeImage(image)"
>
<img :src="crossIcon" alt="Delete" class="h-4 w-4" />
</button>
</div>
</div>
<div v-else>
<Carousel v-bind="adjustedSettings" v-if="combinedImages.length != 0" ref="carousel">
<Slide v-for="image in combinedImages" :key="getKeyForImage(image)">
<div class="carousel__item">
<img :src="getImageSrc(image)" alt="" class="carousel-image" />
</div>
</Slide>
<template #addons>
<Navigation />
<Pagination />
</template>
</Carousel>
</div>
<button
v-if="editMode"
@click.stop.prevent="showModal = true"
class="py-2 px-4 bg-indigo-600 text-white font-semibold rounded hover:bg-blue-700 absolute bottom-0 right-0 z-50"
>
+
</button>
<div
v-if="showModal"
class="fixed inset-0 bg-black/50 flex justify-center items-center z-50"
>
<div class="bg-white p-6 rounded shadow-lg relative w-1/4">
<button
@click.prevent="resetModal"
class="absolute top-2 right-2 text-gray-600 hover:text-gray-900"
>
&times;
</button>
<h3 class="text-lg font-semibold mb-4">Add a New Image</h3>
<div class="flex items-center">
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" />
<button
@click.prevent="triggerFileInput"
class="bg-blue-500 text-white rounded hover:bg-blue-700 h-10 px-4"
>
Browse
</button>
<div
v-if="fileName"
class="ml-4 p-2 border border-gray-300 rounded flex items-center h-10 w-full overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ formatFileName(fileName) }}
</div>
</div>
<button
@click.prevent="addImage"
class="w-full mt-4 bg-blue-500 text-white p-2 rounded hover:bg-blue-700"
>
Add Image
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { crossIcon } from '~/assets/icons'
import { Navigation, Pagination } from 'vue3-carousel'
import type { Image, NewImage, ServerImage } from '~/pages/types/UITypes'
const props = defineProps<{
galleryId: string
serverImages: ServerImage[]
editMode?: boolean
}>()
const emit = defineEmits(['update:modelValue'])
const fileInput = ref<null | HTMLInputElement>(null)
const serverImages = ref<ServerImage[]>([...props.serverImages])
const newImages = ref<NewImage[]>([])
const showModal = ref(false)
const newImageFile = ref<File | null>(null)
const dragIndex = ref<number | null>(null)
const fileName = ref('')
const editMode = props.editMode ?? false
const combinedImages = computed(() => [...serverImages.value, ...newImages.value])
watch(
[serverImages, newImages],
() => {
reassignOrder()
emit('update:modelValue', combinedImages.value)
},
{ deep: true }
)
function handleFileUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) {
fileName.value = file.name
newImageFile.value = file
}
}
function addImage() {
if (newImageFile.value) {
newImages.value.push({
file: newImageFile.value,
order: combinedImages.value.length + 1,
})
resetModal()
}
}
function removeImage(image: Image) {
if ('id' in image) {
const index = serverImages.value.findIndex((img: ServerImage) => img.id === image.id)
if (index !== -1) {
serverImages.value.splice(index, 1)
}
} else {
const index = newImages.value.findIndex((img: NewImage) => img.file === image.file)
if (index !== -1) {
newImages.value.splice(index, 1)
}
}
}
function resetModal() {
newImageFile.value = null
fileName.value = ''
showModal.value = false
}
function triggerFileInput() {
fileInput.value?.click()
}
function dragStart(event: DragEvent, index: number) {
dragIndex.value = index
event.dataTransfer!.effectAllowed = 'move'
}
function handleDragEnter(index: number) {
if (dragIndex.value !== null && index !== dragIndex.value) {
const sourceArray =
dragIndex.value < serverImages.value.length ? serverImages.value : newImages.value
const draggedItem = sourceArray.splice(dragIndex.value, 1)[0]
sourceArray.splice(index, 0, draggedItem as any) // Utilisation de 'as any' pour résoudre le problème de typage
dragIndex.value = index
}
}
function drop() {
dragIndex.value = null
reassignOrder()
}
function reassignOrder() {
combinedImages.value.forEach((image, index) => {
image.order = index + 1
})
}
function getImageSrc(image: Image): string {
return 'link' in image ? image.link : URL.createObjectURL(image.file)
}
function getKeyForImage(image: Image): string {
return 'id' in image ? `server-${image.id}` : `new-${image.file.name}`
}
function formatFileName(fileName: string): string {
const maxLength = 39 // Ajustez cette valeur selon vos besoins
const extension = fileName.split('.').pop()
const nameWithoutExtension = fileName.slice(0, -(extension!.length + 1))
if (fileName.length <= maxLength) {
return fileName
}
const truncatedName = nameWithoutExtension.slice(-maxLength + extension!.length + 3)
return `...${truncatedName}.${extension}`
}
// Carousel settings
// Carousel settings
const settings = {
itemsToShow: 2.5,
snapAlign: 'center',
wrapAround: true,
transition: 500,
}
const adjustedSettings = computed(() => {
const imageCount = combinedImages.value.length
if (imageCount === 1) {
return { ...settings, itemsToShow: 1, wrapAround: false }
} else {
return settings
}
})
</script>
<style scoped>
.carousel__item {
min-height: 310px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.carousel-image {
height: 310px;
object-fit: cover;
object-position: center;
}
.carousel__slide {
padding: 5px;
}
.carousel__viewport {
perspective: 2000px;
}
.carousel__track {
transform-style: preserve-3d;
}
.carousel__slide--sliding {
transition: 0.5s;
}
.carousel__slide {
opacity: 0.9;
transform: rotateY(-20deg) scale(0.9);
}
.carousel__slide--active ~ .carousel__slide {
transform: rotateY(20deg) scale(0.9);
}
.carousel__slide--active {
opacity: 1;
transform: rotateY(0) scale(1.1);
}
.carousel-nav-button {
background-color: var(--color-gray-50);
border-radius: var(--radius-full);
padding: 10px;
box-shadow: var(--shadow-sm);
transition: background-color var(--duration-base) var(--ease-in-out);
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.carousel-nav-button:hover {
background-color: var(--color-gray-100);
}
.carousel-nav-button svg {
width: 24px;
height: 24px;
}
.carousel-nav-button-prev {
left: -30px; /* Adjust as needed */
}
.carousel-nav-button-next {
right: -30px; /* Adjust as needed */
}
</style>