Added page layouts

This commit is contained in:
Francisco Gaona
2025-12-23 09:44:05 +01:00
parent be6e34914e
commit 838a010fb2
26 changed files with 3002 additions and 40 deletions

View File

@@ -0,0 +1,334 @@
<template>
<div class="page-layout-editor">
<div class="flex h-full">
<!-- Main Grid Area -->
<div class="flex-1 p-4 overflow-auto">
<div class="mb-4 flex justify-between items-center">
<h3 class="text-lg font-semibold">{{ layoutName || 'Page Layout' }}</h3>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="handleClear">
Clear All
</Button>
<Button size="sm" @click="handleSave">
Save Layout
</Button>
</div>
</div>
<div class="border rounded-lg bg-slate-50 dark:bg-slate-900 p-4 min-h-[600px]">
<div
ref="gridContainer"
class="grid-stack"
@dragover.prevent="handleDragOver"
@drop="handleDrop"
>
<!-- Grid items will be dynamically added here -->
</div>
</div>
</div>
<!-- Available Fields Sidebar -->
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to grid</p>
<div class="space-y-2" id="sidebar-fields">
<div
v-for="field in availableFields"
:key="field.id"
class="p-3 border rounded cursor-move bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
:data-field-id="field.id"
draggable="true"
@dragstart="handleDragStart($event, field)"
@click="addFieldToGrid(field)"
>
<div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
<div class="text-xs text-muted-foreground">Type: {{ field.type }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { GridStack } from 'gridstack'
import 'gridstack/dist/gridstack.min.css'
import type { FieldLayoutItem } from '~/types/page-layout'
import type { FieldConfig } from '~/types/field-types'
import { Button } from '@/components/ui/button'
const props = defineProps<{
fields: FieldConfig[]
initialLayout?: FieldLayoutItem[]
layoutName?: string
}>()
const emit = defineEmits<{
save: [layout: FieldLayoutItem[]]
}>()
const gridContainer = ref<HTMLElement | null>(null)
let grid: GridStack | null = null
const gridItems = ref<Map<string, any>>(new Map())
// Fields that are already on the grid
const placedFieldIds = ref<Set<string>>(new Set())
// Fields available to be added
const availableFields = computed(() => {
return props.fields.filter(field => !placedFieldIds.value.has(field.id))
})
const initGrid = () => {
if (!gridContainer.value) return
grid = GridStack.init({
column: 6,
cellHeight: 80,
minRow: 10,
float: true,
animate: true,
acceptWidgets: true,
disableOneColumnMode: true,
resizable: {
handles: 'e, w'
}
}, gridContainer.value)
// Listen for changes
grid.on('change', () => {
updatePlacedFields()
})
// Listen for item removal
grid.on('removed', (event, items) => {
items.forEach(item => {
const contentEl = item.el?.querySelector('.grid-stack-item-content')
const fieldId = contentEl?.getAttribute('data-field-id')
if (fieldId) {
placedFieldIds.value.delete(fieldId)
gridItems.value.delete(fieldId)
}
})
})
// Load initial layout if provided
if (props.initialLayout && props.initialLayout.length > 0) {
loadLayout(props.initialLayout)
}
}
const loadLayout = (layout: FieldLayoutItem[]) => {
if (!grid) return
layout.forEach(item => {
const field = props.fields.find(f => f.id === item.fieldId)
if (field) {
addFieldToGrid(field, item.x, item.y, item.w, item.h)
}
})
}
const handleDragStart = (event: DragEvent, field: FieldConfig) => {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/json', JSON.stringify(field));
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
const fieldData = event.dataTransfer?.getData('application/json');
if (!fieldData || !grid) return;
const field = JSON.parse(fieldData);
// Get the grid bounding rect
const gridRect = gridContainer.value?.getBoundingClientRect();
if (!gridRect) return;
// Calculate grid position from drop coordinates
const x = event.clientX - gridRect.left;
const y = event.clientY - gridRect.top;
// Convert pixels to grid coordinates (approx)
const cellWidth = gridRect.width / 6; // 6 columns
const cellHeight = 80; // from our config
const gridX = Math.floor(x / cellWidth);
const gridY = Math.floor(y / cellHeight);
// Add the field at the calculated position
addFieldToGrid(field, gridX, gridY);
};
const addFieldToGrid = (field: FieldConfig, x?: number, y?: number, w: number = 3, h: number = 1) => {
if (!grid || placedFieldIds.value.has(field.id)) return
// Create the widget element manually
const widgetEl = document.createElement('div')
widgetEl.className = 'grid-stack-item'
const contentEl = document.createElement('div')
contentEl.className = 'grid-stack-item-content bg-white dark:bg-slate-900 border rounded p-3 shadow-sm'
contentEl.setAttribute('data-field-id', field.id)
contentEl.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="font-medium text-sm">${field.label}</div>
<div class="text-xs text-muted-foreground">${field.apiName}</div>
<div class="text-xs text-muted-foreground">Type: ${field.type}</div>
</div>
<button class="remove-btn text-destructive hover:text-destructive/80 text-xl leading-none ml-2" type="button">
×
</button>
</div>
`
// Add click handler for remove button
const removeBtn = contentEl.querySelector('.remove-btn')
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (grid) {
grid.removeWidget(widgetEl)
placedFieldIds.value.delete(field.id)
gridItems.value.delete(field.id)
}
})
}
widgetEl.appendChild(contentEl)
// Use makeWidget for GridStack v11+
grid.makeWidget(widgetEl)
// Set grid position after making it a widget
grid.update(widgetEl, {
x: x,
y: y,
w: w,
h: h,
minW: 1,
maxW: 6,
})
placedFieldIds.value.add(field.id)
gridItems.value.set(field.id, widgetEl)
}
const updatePlacedFields = () => {
if (!grid) return
const items = grid.getGridItems()
const newPlacedIds = new Set<string>()
items.forEach(item => {
const contentEl = item.querySelector('.grid-stack-item-content')
const fieldId = contentEl?.getAttribute('data-field-id')
if (fieldId) {
newPlacedIds.add(fieldId)
}
})
placedFieldIds.value = newPlacedIds
}
const handleClear = () => {
if (!grid) return
if (confirm('Are you sure you want to clear all fields from the layout?')) {
grid.removeAll()
placedFieldIds.value.clear()
gridItems.value.clear()
}
}
const handleSave = () => {
if (!grid) return
const items = grid.getGridItems()
const layout: FieldLayoutItem[] = []
items.forEach(item => {
// Look for data-field-id in the content element
const contentEl = item.querySelector('.grid-stack-item-content')
const fieldId = contentEl?.getAttribute('data-field-id')
const node = (item as any).gridstackNode
if (fieldId && node) {
layout.push({
fieldId,
x: node.x,
y: node.y,
w: node.w,
h: node.h,
})
}
})
emit('save', layout)
}
onMounted(() => {
initGrid()
})
onBeforeUnmount(() => {
if (grid) {
grid.destroy(false)
}
})
// Watch for fields changes
watch(() => props.fields, () => {
updatePlacedFields()
}, { deep: true })
</script>
<style>
.grid-stack {
background: transparent;
}
.grid-stack-item {
cursor: move;
}
.grid-stack-item-content {
cursor: move;
overflow: hidden;
}
.grid-stack-item .remove-btn {
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 8px;
}
/* Customize grid appearance */
.grid-stack > .grid-stack-item > .ui-resizable-se {
background: none;
}
.grid-stack > .grid-stack-item > .ui-resizable-handle {
background-color: rgba(0, 0, 0, 0.1);
}
/* Dark mode adjustments */
.dark .grid-stack > .grid-stack-item > .ui-resizable-handle {
background-color: rgba(255, 255, 255, 0.1);
}
</style>