WIP - page layout first version working
This commit is contained in:
287
frontend/components/PageLayoutEditor.vue
Normal file
287
frontend/components/PageLayoutEditor.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<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">
|
||||
<!-- 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 to add field to grid</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="field in availableFields"
|
||||
:key="field.id"
|
||||
class="p-3 border rounded cursor-pointer bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
@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 } 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: 1,
|
||||
float: true,
|
||||
animate: 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 fieldId = item.el?.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) => {
|
||||
// Store field data for drop event
|
||||
event.dataTransfer?.setData('field', JSON.stringify(field))
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user