Files
neo/frontend/components/PageLayoutEditor.vue

374 lines
10 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 space-y-6">
<div>
<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 v-if="relatedLists.length > 0">
<h3 class="text-lg font-semibold mb-2">Related Lists</h3>
<p class="text-xs text-muted-foreground mb-4">Select related lists to show on detail views</p>
<div class="space-y-2">
<label
v-for="list in relatedLists"
:key="list.relationName"
class="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
class="h-4 w-4"
:value="list.relationName"
v-model="selectedRelatedLists"
/>
<span>{{ list.title }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { GridStack } from 'gridstack'
import 'gridstack/dist/gridstack.min.css'
import type { FieldLayoutItem } from '~/types/page-layout'
import type { FieldConfig, RelatedListConfig } from '~/types/field-types'
import { Button } from '@/components/ui/button'
const props = defineProps<{
fields: FieldConfig[]
relatedLists?: RelatedListConfig[]
initialLayout?: FieldLayoutItem[]
initialRelatedLists?: string[]
layoutName?: string
}>()
const emit = defineEmits<{
save: [layout: { fields: FieldLayoutItem[]; relatedLists: string[] }]
}>()
const gridContainer = ref<HTMLElement | null>(null)
let grid: GridStack | null = null
const gridItems = ref<Map<string, any>>(new Map())
const selectedRelatedLists = ref<string[]>(props.initialRelatedLists || [])
// 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 relatedLists = computed(() => {
return props.relatedLists || []
})
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', {
fields: layout,
relatedLists: selectedRelatedLists.value,
})
}
onMounted(() => {
initGrid()
})
onBeforeUnmount(() => {
if (grid) {
grid.destroy(false)
}
})
// Watch for fields changes
watch(() => props.fields, () => {
updatePlacedFields()
}, { deep: true })
watch(
() => props.initialRelatedLists,
(value) => {
selectedRelatedLists.value = value ? [...value] : []
},
)
</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>