Initial commit: PC Builder AdonisJS project
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
authError?: UIAuthError
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="authError"
|
||||
class="bg-red-100 border border-red-400 text-red-700 text-center text-sm rounded relative px-4 py-1 mt-1 w-full flex items-center justify-center space-x-2"
|
||||
>
|
||||
<div class="h-4 w-4 shrink-0">
|
||||
<svg
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 554.2 554.199"
|
||||
xml:space="preserve"
|
||||
fill="currentColor"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M538.5,386.199L356.5,70.8c-16.4-28.4-46.7-45.9-79.501-45.9c-32.8,0-63.1,17.5-79.5,45.9L12.3,391.6
|
||||
c-16.4,28.4-16.4,63.4,0,91.8C28.7,511.8,59,529.3,91.8,529.3H462.2c0.101,0,0.2,0,0.2,0c50.7,0,91.8-41.101,91.8-91.8
|
||||
C554.2,418.5,548.4,400.8,538.5,386.199z M316.3,416.899c0,21.7-16.7,38.3-39.2,38.3s-39.2-16.6-39.2-38.3V416
|
||||
c0-21.601,16.7-38.301,39.2-38.301S316.3,394.3,316.3,416V416.899z M317.2,158.7L297.8,328.1c-1.3,12.2-9.4,19.8-20.7,19.8
|
||||
s-19.4-7.7-20.7-19.8L237,158.6c-1.3-13.1,5.801-23,18-23H299.1C311.3,135.7,318.5,145.6,317.2,158.7z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="block sm:inline whitespace-nowrap">{{ authError.message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineProps, onMounted, ref, watch } from 'vue'
|
||||
import { Component } from '~/pages/types/UITypes'
|
||||
|
||||
interface ComponentsByType {
|
||||
type: string
|
||||
components: Component[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
availableComponents: Component[]
|
||||
selectedComponents: number[]
|
||||
initialSelectedComponents?: Component[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:components'])
|
||||
|
||||
const componentTypeOrder = [
|
||||
'Processor',
|
||||
'Motherboard',
|
||||
'Graphic Card',
|
||||
'Memory',
|
||||
'HDD',
|
||||
'SSD',
|
||||
'Cooler',
|
||||
'Power Supply',
|
||||
'Case',
|
||||
'WiFi-Card',
|
||||
'Fan',
|
||||
'Other',
|
||||
]
|
||||
|
||||
// Fusionner les composants disponibles et initialement s├®lectionn├®s
|
||||
const allComponents = computed(() => {
|
||||
const mergedComponents = [...props.availableComponents]
|
||||
if (props.initialSelectedComponents) {
|
||||
props.initialSelectedComponents.forEach((component) => {
|
||||
if (!mergedComponents.find((c) => c.id === component.id)) {
|
||||
mergedComponents.push(component)
|
||||
}
|
||||
})
|
||||
}
|
||||
return mergedComponents
|
||||
})
|
||||
|
||||
// Trier les composants par type
|
||||
const componentsByType = computed(() => {
|
||||
const groupedComponents: Record<string, Component[]> = {}
|
||||
|
||||
allComponents.value.forEach((component) => {
|
||||
const type = component.type.name
|
||||
if (!groupedComponents[type]) {
|
||||
groupedComponents[type] = []
|
||||
}
|
||||
groupedComponents[type].push(component)
|
||||
})
|
||||
|
||||
const sortedComponentsByType: ComponentsByType[] = componentTypeOrder.map((type) => ({
|
||||
type,
|
||||
components: groupedComponents[type] || [],
|
||||
}))
|
||||
|
||||
return sortedComponentsByType.filter((group) => group.components.length > 0)
|
||||
})
|
||||
|
||||
// Initialisation de selectedComponents
|
||||
let selectedComponents = ref<number[]>(
|
||||
props.initialSelectedComponents?.map((c) => c.id) || props.selectedComponents || []
|
||||
)
|
||||
// Synchroniser les props avec le composant
|
||||
watch(
|
||||
() => props.selectedComponents,
|
||||
(newVal) => {
|
||||
selectedComponents.value = newVal
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.initialSelectedComponents) {
|
||||
emit(
|
||||
'update:components',
|
||||
props.initialSelectedComponents.map((c) => c.id)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function updateComponents(event: Event, type: string) {
|
||||
const selectedComponentId = Number((event.target as HTMLSelectElement).value)
|
||||
const updatedComponents = [...selectedComponents.value]
|
||||
|
||||
const alreadyInListComponentIndex = updatedComponents.findIndex((id) => {
|
||||
return selectedComponentId === id
|
||||
})
|
||||
|
||||
if (alreadyInListComponentIndex !== -1) {
|
||||
updatedComponents.splice(alreadyInListComponentIndex, 1)
|
||||
} else {
|
||||
const typeIndex = componentsByType.value.findIndex((item) => item.type === type)
|
||||
const existingComponentIndex = updatedComponents.findIndex((id) => {
|
||||
const component = componentsByType.value[typeIndex].components.find((c) => c.id === id)
|
||||
return component && component.type.unique
|
||||
})
|
||||
if (existingComponentIndex !== -1) updatedComponents.splice(existingComponentIndex, 1)
|
||||
|
||||
if (selectedComponentId != 0) updatedComponents.push(Number(selectedComponentId))
|
||||
}
|
||||
|
||||
selectedComponents.value = updatedComponents
|
||||
emit('update:components', updatedComponents)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="{ type, components } in componentsByType" :key="type">
|
||||
<label :for="`select-${type}`" class="block text-gray-700 text-sm font-bold mb-1 mt-2">
|
||||
{{ type }}
|
||||
</label>
|
||||
<div v-if="components[0]?.type.unique">
|
||||
<!-- Utilisation d'un select pour les composants uniques -->
|
||||
<select
|
||||
@change="updateComponents($event, type)"
|
||||
:id="`select-${type}`"
|
||||
class="w-full mt-2 border-gray-200 focus:border-indigo-600 focus:ring-indigo-500 px-3 py-2 focus:ring-1 border rounded text-gray-700 bg-white"
|
||||
>
|
||||
<option value="" selected>Select a component</option>
|
||||
<option
|
||||
v-for="component in components"
|
||||
:key="component.id"
|
||||
:value="component.id"
|
||||
:selected="selectedComponents.includes(component.id)"
|
||||
>
|
||||
{{ component.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Utilisation de cases à cocher pour les composants non uniques avec grille responsive -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div v-for="component in components" :key="component.id" class="flex items-center mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
@click="updateComponents($event, type)"
|
||||
:id="`checkbox-${component.id}`"
|
||||
:value="component.id"
|
||||
v-model="selectedComponents"
|
||||
class="mr-2"
|
||||
:checked="selectedComponents.includes(component.id)"
|
||||
/>
|
||||
<label :for="`checkbox-${component.id}`" class="text-sm text-gray-700">
|
||||
{{ component.name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import TableComponent from '~/pages/widgets/TableComponent.vue'
|
||||
import Component from '#models/component'
|
||||
import ComponentType from '#models/component_type'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
|
||||
const { props } = usePage<{ components: Component[] }>()
|
||||
const { components } = props
|
||||
|
||||
const usageDropdownOpen = ref(false)
|
||||
const typeDropdownOpen = ref(false)
|
||||
|
||||
function toggleDropdown(dropdown: any) {
|
||||
if (dropdown === 'usage') {
|
||||
usageDropdownOpen.value = !usageDropdownOpen.value
|
||||
typeDropdownOpen.value = false
|
||||
} else if (dropdown === 'type') {
|
||||
typeDropdownOpen.value = !typeDropdownOpen.value
|
||||
usageDropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const componentTypes = computed(() => {
|
||||
const types = new Map<number, ComponentType>()
|
||||
components.forEach((component) => {
|
||||
if (!types.has(component.type.id)) {
|
||||
types.set(component.type.id, component.type)
|
||||
}
|
||||
})
|
||||
return Array.from(types.values())
|
||||
})
|
||||
|
||||
// D├®finir le type de l'objet filters
|
||||
interface Filters {
|
||||
showUsed: boolean
|
||||
showUnused: boolean
|
||||
types: { [key: number]: boolean }
|
||||
}
|
||||
|
||||
const filters = reactive<Filters>({
|
||||
showUsed: false,
|
||||
showUnused: true,
|
||||
types: componentTypes.value.reduce(
|
||||
(acc, type) => {
|
||||
acc[type.id] = true
|
||||
return acc
|
||||
},
|
||||
{} as { [key: number]: boolean }
|
||||
),
|
||||
})
|
||||
|
||||
const filteredComponents = computed(() => {
|
||||
return components.filter((component) => {
|
||||
const isUsed = filters.showUsed && component.computerId !== null
|
||||
const isUnused = filters.showUnused && component.computerId === null
|
||||
const isTypeSelected = filters.types[component.type.id]
|
||||
return (isUsed || isUnused) && isTypeSelected
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<!-- Dropdown pour les filtres -->
|
||||
<div class="relative">
|
||||
<h4 class="text-xl underline">Filters</h4>
|
||||
<div class="flex gap-2">
|
||||
<!-- Usage Dropdown -->
|
||||
<div id="usage-selector" class="relative" style="width: fit-content">
|
||||
<button
|
||||
@click="toggleDropdown('usage')"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
||||
type="button"
|
||||
>
|
||||
Usage
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-show="usageDropdownOpen"
|
||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
||||
>
|
||||
<ul class="space-y-2 text-sm px-4 py-2">
|
||||
<li class="flex items-center">
|
||||
<input
|
||||
id="unused"
|
||||
type="checkbox"
|
||||
v-model="filters.showUnused"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label
|
||||
for="unused"
|
||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Unused
|
||||
</label>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<input
|
||||
id="used"
|
||||
type="checkbox"
|
||||
v-model="filters.showUsed"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Used
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Dropdown -->
|
||||
<div id="type-selector" class="relative" style="width: fit-content">
|
||||
<button
|
||||
@click="toggleDropdown('type')"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
||||
type="button"
|
||||
>
|
||||
Types
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-show="typeDropdownOpen"
|
||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
||||
>
|
||||
<ul class="space-y-2 text-sm px-4 py-2">
|
||||
<li class="flex items-center" v-for="type in componentTypes" :key="type.id">
|
||||
<input
|
||||
:id="`type-${type.id}`"
|
||||
type="checkbox"
|
||||
v-model="filters.types[type.id]"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label
|
||||
:for="`type-${type.id}`"
|
||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{{ type.name }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau des composants filtr├®s -->
|
||||
<TableComponent :components="filteredComponents" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { useAdminMode } from '~/composables/isAdminMode'
|
||||
|
||||
const props = defineProps({
|
||||
computerId: Number,
|
||||
imageSrc: String,
|
||||
imageAlt: String,
|
||||
title: String,
|
||||
description: String,
|
||||
cost: Number,
|
||||
price: Number,
|
||||
})
|
||||
|
||||
const { isAdminActive } = useAdminMode()
|
||||
|
||||
function goToShow() {
|
||||
router.get(`/computers/${props.computerId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg bg-white shadow-secondary-1 dark:bg-surface-dark h-full flex flex-col hover:cursor-pointer"
|
||||
@click="goToShow"
|
||||
>
|
||||
<img class="w-full h-2/3 object-cover" :src="imageSrc" :alt="imageAlt" />
|
||||
<div class="flex flex-col justify-between grow px-6 py-2">
|
||||
<div class="text-xl font-bold text-gray-900 line-clamp-4">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div class="flex justify-end mt-auto">
|
||||
<span
|
||||
v-if="isAdminActive"
|
||||
class="inline-block px-3 py-1 mb-2 mr-2 text-sm font-semibold text-gray-700 bg-gray-200 rounded-full"
|
||||
>
|
||||
{{ props.cost?.toFixed(2) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-3 py-1 mb-2 mr-2 text-sm font-semibold text-gray-700 bg-gray-200 rounded-full"
|
||||
>
|
||||
{{ props.price?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount } from 'vue'
|
||||
import {
|
||||
cooler,
|
||||
cpu,
|
||||
defaultIcon,
|
||||
fan,
|
||||
graphicCard,
|
||||
hardDrive,
|
||||
memory,
|
||||
motherboard,
|
||||
pcCase,
|
||||
powerSupply,
|
||||
ssd,
|
||||
wiFiCard,
|
||||
} from '~/assets/icons'
|
||||
import Component from '#models/component'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps<{
|
||||
components: Component[]
|
||||
isAdminActive: boolean
|
||||
}>()
|
||||
const components = props.components
|
||||
const originHeader = { 'X-Inertia-Origin': 'computer' }
|
||||
|
||||
let cost = 0
|
||||
onBeforeMount(() => {
|
||||
components.forEach((component: Component) => {
|
||||
cost += parseFloat(String(component.price))
|
||||
})
|
||||
})
|
||||
|
||||
const componentTypeOrder = [
|
||||
'Processor',
|
||||
'Motherboard',
|
||||
'Graphic Card',
|
||||
'Memory',
|
||||
'SSD',
|
||||
'Storage',
|
||||
'Cooler',
|
||||
'Power Supply',
|
||||
'Case',
|
||||
'WiFi-Card',
|
||||
'Fan',
|
||||
'Other',
|
||||
]
|
||||
|
||||
// Trier les composants selon l'ordre sp├®cifi├®
|
||||
const sortedComponents = computed(() => {
|
||||
return components.slice().sort((a, b) => {
|
||||
return componentTypeOrder.indexOf(a.type.name) - componentTypeOrder.indexOf(b.type.name)
|
||||
})
|
||||
})
|
||||
|
||||
function getIconPath(typeName: string): string {
|
||||
const typeToIconMap: Record<string, string> = {
|
||||
'Motherboard': motherboard,
|
||||
'Processor': cpu,
|
||||
'Memory': memory,
|
||||
'HDD': hardDrive,
|
||||
'SSD': ssd,
|
||||
'Cooler': cooler,
|
||||
'Graphic Card': graphicCard,
|
||||
'Power Supply': powerSupply,
|
||||
'Fan': fan,
|
||||
'WiFi-Card': wiFiCard,
|
||||
'Case': pcCase,
|
||||
}
|
||||
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<table class="w-full mb-4 text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="px-4 py-2 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-400 bg-gray-50"
|
||||
>
|
||||
<td v-if="isAdminActive" colspan="5"></td>
|
||||
<td v-else colspan="4"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(component, index) in sortedComponents"
|
||||
:key="component.id"
|
||||
:class="{ 'bg-gray-200': index % 2 !== 0 }"
|
||||
>
|
||||
<td class="px-4 py-2 border-b border-gray-400">
|
||||
<img
|
||||
:src="getIconPath(component.type.name)"
|
||||
:alt="`${component.type.name} Icon`"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-2 border-b border-gray-400">
|
||||
{{ component.type.name }}
|
||||
</td>
|
||||
<td
|
||||
class="px-4 py-2 border-b border-gray-400"
|
||||
:class="[isAdminActive ? '' : 'text-right']"
|
||||
>
|
||||
{{ component.name }}
|
||||
</td>
|
||||
|
||||
<td v-if="isAdminActive" class="px-4 py-2 border-b border-gray-400 text-right">
|
||||
Ôé¼{{ component.price }}
|
||||
</td>
|
||||
<td class="px-4 py-2 border-b border-gray-400 flex justify-center items-center">
|
||||
<Link
|
||||
:href="`/components/${component.id}/edit`"
|
||||
method="get"
|
||||
as="button"
|
||||
class="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-sm"
|
||||
:headers="originHeader"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="isAdminActive">
|
||||
<tr class="bg-gray-800 text-white">
|
||||
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap" colspan="3">Total</td>
|
||||
<td class="px-4 py-2 border-b border-gray-400 whitespace-nowrap text-right">
|
||||
Ôé¼{{ cost.toFixed(2) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { noImagePlaceholder } from '~/assets/images'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import Computer from '#models/computer'
|
||||
import ComputerCard from '~/pages/widgets/ComputerCard.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const { computers } = usePage<{ computers: Computer[] }>().props
|
||||
const stateDropdownOpen = ref(false)
|
||||
|
||||
function toggleDropdown() {
|
||||
stateDropdownOpen.value = !stateDropdownOpen.value
|
||||
}
|
||||
|
||||
const filters = reactive({
|
||||
onGoing: true,
|
||||
finished: true,
|
||||
sold: false,
|
||||
})
|
||||
const filteredComputers = computed(() => {
|
||||
return computers.filter((computer) => {
|
||||
const isOnGoing = filters.onGoing && computer.state.name == 'ON_GOING'
|
||||
const isFinished = filters.finished && computer.state.name == 'FINISHED'
|
||||
const isSold = filters.sold && computer.state.name == 'SOLD'
|
||||
return isOnGoing || isFinished || isSold
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Dropdown pour les filtres -->
|
||||
<div class="relative">
|
||||
<h4 class="text-xl underline">Filters</h4>
|
||||
<div class="flex gap-2">
|
||||
<!-- Usage Dropdown -->
|
||||
<div id="usage-selector" class="relative" style="width: fit-content">
|
||||
<button
|
||||
@click="toggleDropdown()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-blue-700 transition text-center inline-flex items-center"
|
||||
type="button"
|
||||
>
|
||||
Usage
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-show="stateDropdownOpen"
|
||||
class="absolute right-0 z-20 py-2 mt-2 bg-white rounded-md shadow-xl"
|
||||
>
|
||||
<ul class="space-y-2 text-sm px-4 py-2">
|
||||
<li class="flex items-center">
|
||||
<input
|
||||
id="unused"
|
||||
type="checkbox"
|
||||
v-model="filters.onGoing"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label
|
||||
for="unused"
|
||||
class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
On Going
|
||||
</label>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<input
|
||||
id="used"
|
||||
type="checkbox"
|
||||
v-model="filters.finished"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Finished
|
||||
</label>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<input
|
||||
id="used"
|
||||
type="checkbox"
|
||||
v-model="filters.sold"
|
||||
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
/>
|
||||
<label for="used" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Sold
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 mb-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<ComputerCard
|
||||
v-for="computer in filteredComputers"
|
||||
:key="computer.id"
|
||||
:computerId="computer.id"
|
||||
:image-src="computer.pictures.find((p) => p.order === 1)?.link || noImagePlaceholder"
|
||||
image-alt="Computer Image"
|
||||
:title="computer.title"
|
||||
:cost="computer.cost"
|
||||
:price="computer.sellPrice"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps } from 'vue'
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div class="fixed inset-0 bg-black opacity-50"></div>
|
||||
<div class="bg-white rounded-lg p-6 z-10">
|
||||
<h2 class="text-lg font-semibold mb-4">Confirmation</h2>
|
||||
<p class="mb-4">Are you sure to delete this item ?</p>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button @click="confirm" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-700">
|
||||
Ok
|
||||
</button>
|
||||
<button
|
||||
@click="cancel"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Styles pour le modal */
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<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"
|
||||
>
|
||||
×
|
||||
</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 { NewImage, ServerImage } from '../../../app/types/UItypes'
|
||||
import { Image } 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) => img.id === image.id)
|
||||
if (index !== -1) {
|
||||
serverImages.value.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
const index = newImages.value.findIndex((img) => 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>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
cooler,
|
||||
cpu,
|
||||
defaultIcon,
|
||||
fan,
|
||||
graphicCard,
|
||||
hardDrive,
|
||||
memory,
|
||||
motherboard,
|
||||
pcCase,
|
||||
powerSupply,
|
||||
ssd,
|
||||
trashIcon,
|
||||
wiFiCard,
|
||||
} from '~/assets/icons'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { useAdminMode } from '~/composables/isAdminMode'
|
||||
import Component from '#models/component'
|
||||
|
||||
const props = defineProps<{
|
||||
components: Component[]
|
||||
}>()
|
||||
|
||||
const { isAdminActive } = useAdminMode()
|
||||
|
||||
function getIconPath(typeName: string): string {
|
||||
const typeToIconMap: Record<string, string> = {
|
||||
'Motherboard': motherboard,
|
||||
'Processor': cpu,
|
||||
'Memory': memory,
|
||||
'HDD': hardDrive,
|
||||
'SSD': ssd,
|
||||
'Cooler': cooler,
|
||||
'Graphic Card': graphicCard,
|
||||
'Power Supply': powerSupply,
|
||||
'Fan': fan,
|
||||
'WiFi-Card': wiFiCard,
|
||||
'Case': pcCase,
|
||||
}
|
||||
return typeToIconMap[typeName] || defaultIcon // Utilisez une ic├┤ne par d├®faut si n├®cessaire
|
||||
}
|
||||
|
||||
function goToDetail(id: number) {
|
||||
router.get(`/components/${id}`)
|
||||
}
|
||||
|
||||
function deleteComponent(id: number) {
|
||||
router.delete(`/components/${id}`)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col mt-8">
|
||||
<div class="py-2 -my-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div
|
||||
class="inline-block min-w-full overflow-hidden align-middle border-b border-gray-200 shadow sm:rounded-lg"
|
||||
>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-200 bg-gray-50"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-200 bg-gray-50"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdminActive"
|
||||
class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-200 bg-gray-50"
|
||||
>
|
||||
Price
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-200 bg-gray-50"
|
||||
>
|
||||
Purchase Date
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-500 uppercase border-b border-gray-200 bg-gray-50"
|
||||
>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white">
|
||||
<tr
|
||||
v-for="component in components"
|
||||
:key="component.id"
|
||||
@click.stop.prevent="goToDetail(component.id)"
|
||||
class="cursor-pointer hover:bg-indigo-600/50"
|
||||
:class="[component.computerId !== null ? 'used bg-gray-400' : 'bg-white']"
|
||||
>
|
||||
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
|
||||
<img
|
||||
:src="getIconPath(component.type.name)"
|
||||
:alt="`${component.type.name} Icon`"
|
||||
style="width: 32px; height: 32px"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
|
||||
{{ component.name }}
|
||||
</td>
|
||||
<td v-if="isAdminActive" class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
|
||||
Ôé¼{{ component.price }}
|
||||
</td>
|
||||
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
|
||||
{{ component.purchaseDate }}
|
||||
</td>
|
||||
<td class="px-6 py-4 border-b border-gray-200 whitespace-nowrap">
|
||||
<button
|
||||
v-delete-confirm="() => deleteComponent(component.id)"
|
||||
class="px-4 py-2 bg-gray-300 text-white rounded hover:bg-red-500 transition inline-flex items-center justify-center"
|
||||
>
|
||||
<img :src="trashIcon" alt="Delete" class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 text-xs rounded relative px-1">
|
||||
<strong class="me-0.5">!</strong>
|
||||
<span class="block sm:inline whitespace-nowrap">{{ message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user