rivet_cms 0.1.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +8 -0
- data/app/assets/builds/rivet_cms.css +2 -0
- data/app/assets/builds/rivet_cms.js +9536 -0
- data/app/assets/builds/rivet_cms.js.map +7 -0
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +25 -0
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +168 -0
- data/app/controllers/rivet_cms/api/docs_controller.rb +38 -0
- data/app/controllers/rivet_cms/application_controller.rb +5 -0
- data/app/controllers/rivet_cms/components_controller.rb +7 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +61 -0
- data/app/controllers/rivet_cms/dashboard_controller.rb +10 -0
- data/app/controllers/rivet_cms/fields_controller.rb +109 -0
- data/app/helpers/rivet_cms/application_helper.rb +7 -0
- data/app/helpers/rivet_cms/brand_color_helper.rb +71 -0
- data/app/helpers/rivet_cms/flash_helper.rb +37 -0
- data/app/helpers/rivet_cms/sign_out_helper.rb +11 -0
- data/app/javascript/controllers/content_type_form_controller.js +53 -0
- data/app/javascript/controllers/field_layout_controller.js +709 -0
- data/app/javascript/rivet_cms.js +29 -0
- data/app/jobs/rivet_cms/application_job.rb +4 -0
- data/app/mailers/rivet_cms/application_mailer.rb +6 -0
- data/app/models/rivet_cms/application_record.rb +5 -0
- data/app/models/rivet_cms/component.rb +4 -0
- data/app/models/rivet_cms/content.rb +4 -0
- data/app/models/rivet_cms/content_type.rb +40 -0
- data/app/models/rivet_cms/content_value.rb +4 -0
- data/app/models/rivet_cms/field.rb +82 -0
- data/app/models/rivet_cms/field_values/base.rb +11 -0
- data/app/models/rivet_cms/field_values/boolean.rb +4 -0
- data/app/models/rivet_cms/field_values/integer.rb +4 -0
- data/app/models/rivet_cms/field_values/string.rb +4 -0
- data/app/models/rivet_cms/field_values/text.rb +4 -0
- data/app/services/rivet_cms/open_api_generator.rb +245 -0
- data/app/views/layouts/rivet_cms/application.html.erb +49 -0
- data/app/views/rivet_cms/api/docs/show.html.erb +47 -0
- data/app/views/rivet_cms/content_types/_form.html.erb +98 -0
- data/app/views/rivet_cms/content_types/edit.html.erb +27 -0
- data/app/views/rivet_cms/content_types/index.html.erb +151 -0
- data/app/views/rivet_cms/content_types/new.html.erb +19 -0
- data/app/views/rivet_cms/content_types/show.html.erb +147 -0
- data/app/views/rivet_cms/dashboard/index.html.erb +263 -0
- data/app/views/rivet_cms/fields/_form.html.erb +111 -0
- data/app/views/rivet_cms/fields/edit.html.erb +25 -0
- data/app/views/rivet_cms/fields/index.html.erb +126 -0
- data/app/views/rivet_cms/fields/new.html.erb +25 -0
- data/app/views/rivet_cms/shared/_navigation.html.erb +153 -0
- data/config/i18n-tasks.yml +178 -0
- data/config/locales/en.yml +14 -0
- data/config/routes.rb +56 -0
- data/db/migrate/20250317194359_create_core_tables.rb +90 -0
- data/lib/rivet_cms/engine.rb +55 -0
- data/lib/rivet_cms/version.rb +3 -0
- data/lib/rivet_cms.rb +44 -0
- data/lib/tasks/rivet_cms_tasks.rake +4 -0
- metadata +231 -0
@@ -0,0 +1,709 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
/**
|
4
|
+
* Field Layout Controller
|
5
|
+
*
|
6
|
+
* Manages the drag-and-drop functionality for field layout in the content editor.
|
7
|
+
* Allows for reordering fields, placing them side by side, and adjusting their width.
|
8
|
+
*/
|
9
|
+
export default class extends Controller {
|
10
|
+
static targets = ["field"]
|
11
|
+
static values = {
|
12
|
+
contentTypeId: String,
|
13
|
+
updatePositionsPath: String
|
14
|
+
}
|
15
|
+
|
16
|
+
// Configuration constants
|
17
|
+
static config = {
|
18
|
+
dragDetection: {
|
19
|
+
extendedLeft: 200, // Extended detection area to the left (px)
|
20
|
+
priorityFactor: 0.1, // Factor to prioritize fields in drag direction
|
21
|
+
verticalThreshold: 30 // Threshold for vertical distance detection (px)
|
22
|
+
},
|
23
|
+
styling: {
|
24
|
+
gap: "0.75rem",
|
25
|
+
padding: "0.75rem",
|
26
|
+
ghostOpacity: "0.7",
|
27
|
+
ghostScale: "1.02"
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
/**
|
32
|
+
* Initialize the controller when connected to the DOM
|
33
|
+
*/
|
34
|
+
connect() {
|
35
|
+
this.handleDragMove = this.handleDragMove.bind(this);
|
36
|
+
this.handleDragEnd = this.handleDragEnd.bind(this);
|
37
|
+
this.setupGridLayout();
|
38
|
+
this.applyBaseStyles();
|
39
|
+
this.initializeDragAndDrop();
|
40
|
+
this.updateLayout();
|
41
|
+
}
|
42
|
+
|
43
|
+
/**
|
44
|
+
* Set up the initial grid layout
|
45
|
+
*/
|
46
|
+
setupGridLayout() {
|
47
|
+
this.element.style.display = "grid";
|
48
|
+
this.element.style.gridTemplateColumns = "1fr 1fr";
|
49
|
+
this.element.style.gap = this.constructor.config.styling.gap;
|
50
|
+
}
|
51
|
+
|
52
|
+
/**
|
53
|
+
* Apply base styles to all field elements
|
54
|
+
*/
|
55
|
+
applyBaseStyles() {
|
56
|
+
this.fieldTargets.forEach(field => {
|
57
|
+
field.classList.add(
|
58
|
+
'bg-gray-50',
|
59
|
+
'hover:bg-gray-100',
|
60
|
+
'rounded-lg',
|
61
|
+
'transition-colors'
|
62
|
+
);
|
63
|
+
});
|
64
|
+
}
|
65
|
+
|
66
|
+
/**
|
67
|
+
* Initialize drag and drop functionality
|
68
|
+
*/
|
69
|
+
initializeDragAndDrop() {
|
70
|
+
// Initialize drag state
|
71
|
+
this.resetDragState();
|
72
|
+
|
73
|
+
// Add event listeners to drag handles
|
74
|
+
this.fieldTargets.forEach(field => {
|
75
|
+
const handle = field.querySelector('.cursor-move');
|
76
|
+
if (!handle) return;
|
77
|
+
|
78
|
+
handle.addEventListener('mousedown', (e) => {
|
79
|
+
e.preventDefault();
|
80
|
+
this.startDragging(field, e);
|
81
|
+
});
|
82
|
+
});
|
83
|
+
}
|
84
|
+
|
85
|
+
/**
|
86
|
+
* Reset all drag-related state variables
|
87
|
+
*/
|
88
|
+
resetDragState() {
|
89
|
+
this.draggedField = null;
|
90
|
+
this.dragStartY = 0;
|
91
|
+
this.dragStartX = 0;
|
92
|
+
this.ghostElement = null;
|
93
|
+
this.dropTarget = null;
|
94
|
+
this.dropPosition = null;
|
95
|
+
this.dropZone = null;
|
96
|
+
this.originalTop = 0;
|
97
|
+
this.originalLeft = 0;
|
98
|
+
}
|
99
|
+
|
100
|
+
/**
|
101
|
+
* Start dragging a field
|
102
|
+
* @param {HTMLElement} field - The field element being dragged
|
103
|
+
* @param {MouseEvent} event - The mousedown event
|
104
|
+
*/
|
105
|
+
startDragging(field, event) {
|
106
|
+
this.draggedField = field;
|
107
|
+
this.dragStartY = event.clientY;
|
108
|
+
this.dragStartX = event.clientX;
|
109
|
+
|
110
|
+
// Store original position
|
111
|
+
const rect = field.getBoundingClientRect();
|
112
|
+
this.originalTop = rect.top;
|
113
|
+
this.originalLeft = rect.left;
|
114
|
+
|
115
|
+
// Create and position ghost element
|
116
|
+
this.createGhostElement(field, rect);
|
117
|
+
|
118
|
+
// Hide original field
|
119
|
+
field.style.opacity = '0';
|
120
|
+
|
121
|
+
// Add event listeners for drag operations
|
122
|
+
document.addEventListener('mousemove', this.handleDragMove);
|
123
|
+
document.addEventListener('mouseup', this.handleDragEnd);
|
124
|
+
}
|
125
|
+
|
126
|
+
/**
|
127
|
+
* Create a ghost element for dragging
|
128
|
+
* @param {HTMLElement} field - The field being dragged
|
129
|
+
* @param {DOMRect} rect - The bounding rectangle of the field
|
130
|
+
*/
|
131
|
+
createGhostElement(field, rect) {
|
132
|
+
const { ghostOpacity, ghostScale } = this.constructor.config.styling;
|
133
|
+
|
134
|
+
this.ghostElement = field.cloneNode(true);
|
135
|
+
this.ghostElement.style.position = 'fixed';
|
136
|
+
this.ghostElement.style.top = `${rect.top}px`;
|
137
|
+
this.ghostElement.style.left = `${rect.left}px`;
|
138
|
+
this.ghostElement.style.width = `${rect.width}px`;
|
139
|
+
this.ghostElement.style.opacity = ghostOpacity;
|
140
|
+
this.ghostElement.style.pointerEvents = 'none';
|
141
|
+
this.ghostElement.style.zIndex = '1000';
|
142
|
+
this.ghostElement.style.transform = `scale(${ghostScale})`;
|
143
|
+
this.ghostElement.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';
|
144
|
+
|
145
|
+
document.body.appendChild(this.ghostElement);
|
146
|
+
}
|
147
|
+
|
148
|
+
/**
|
149
|
+
* Handle mouse movement during drag
|
150
|
+
* @param {MouseEvent} event - The mousemove event
|
151
|
+
*/
|
152
|
+
handleDragMove = (event) => {
|
153
|
+
if (!this.draggedField || !this.ghostElement) return;
|
154
|
+
|
155
|
+
// Move ghost element
|
156
|
+
this.moveGhostElement(event);
|
157
|
+
|
158
|
+
// Clear existing indicators
|
159
|
+
this.clearDropIndicators();
|
160
|
+
|
161
|
+
// Find closest field and show drop indicator
|
162
|
+
const closestField = this.findClosestField(event);
|
163
|
+
if (!closestField) return;
|
164
|
+
|
165
|
+
this.showDropIndicator(closestField, event);
|
166
|
+
}
|
167
|
+
|
168
|
+
/**
|
169
|
+
* Move the ghost element with the mouse
|
170
|
+
* @param {MouseEvent} event - The mousemove event
|
171
|
+
*/
|
172
|
+
moveGhostElement(event) {
|
173
|
+
const deltaX = event.clientX - this.dragStartX;
|
174
|
+
const deltaY = event.clientY - this.dragStartY;
|
175
|
+
const { ghostScale } = this.constructor.config.styling;
|
176
|
+
|
177
|
+
this.ghostElement.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${ghostScale})`;
|
178
|
+
}
|
179
|
+
|
180
|
+
/**
|
181
|
+
* Clear all drop indicators
|
182
|
+
*/
|
183
|
+
clearDropIndicators() {
|
184
|
+
this.fieldTargets.forEach(field => {
|
185
|
+
field.classList.remove('bg-gray-100');
|
186
|
+
const dropZone = field.querySelector('.drop-indicator');
|
187
|
+
if (dropZone) dropZone.remove();
|
188
|
+
});
|
189
|
+
}
|
190
|
+
|
191
|
+
/**
|
192
|
+
* Find the closest field to the current mouse position
|
193
|
+
* @param {MouseEvent} event - The mousemove event
|
194
|
+
* @returns {HTMLElement|null} - The closest field or null if none found
|
195
|
+
*/
|
196
|
+
findClosestField(event) {
|
197
|
+
const mouseX = event.clientX;
|
198
|
+
const mouseY = event.clientY;
|
199
|
+
let closestField = null;
|
200
|
+
let minDistance = Infinity;
|
201
|
+
|
202
|
+
const { extendedLeft, priorityFactor } = this.constructor.config.dragDetection;
|
203
|
+
|
204
|
+
this.fieldTargets.forEach(field => {
|
205
|
+
if (field === this.draggedField) return;
|
206
|
+
|
207
|
+
const rect = field.getBoundingClientRect();
|
208
|
+
const extendedRect = {
|
209
|
+
top: rect.top - 8,
|
210
|
+
bottom: rect.bottom + 8,
|
211
|
+
left: rect.left - extendedLeft,
|
212
|
+
right: rect.right + 8,
|
213
|
+
width: rect.width + extendedLeft + 8,
|
214
|
+
height: rect.height + 16
|
215
|
+
};
|
216
|
+
|
217
|
+
if (mouseX >= extendedRect.left && mouseX <= extendedRect.right &&
|
218
|
+
mouseY >= extendedRect.top && mouseY <= extendedRect.bottom) {
|
219
|
+
|
220
|
+
// Determine if we're dragging left-to-right
|
221
|
+
const isLeftToRight = this.originalLeft < rect.left;
|
222
|
+
|
223
|
+
// For left-to-right dragging, prioritize fields to the right
|
224
|
+
let distance = Math.abs(mouseY - (rect.top + rect.height / 2));
|
225
|
+
|
226
|
+
if (isLeftToRight && rect.left > this.originalLeft) {
|
227
|
+
// This is a field to the right of the dragged field
|
228
|
+
// Give it a much lower distance to prioritize it
|
229
|
+
distance = distance * priorityFactor;
|
230
|
+
}
|
231
|
+
|
232
|
+
if (distance < minDistance) {
|
233
|
+
minDistance = distance;
|
234
|
+
closestField = field;
|
235
|
+
}
|
236
|
+
}
|
237
|
+
});
|
238
|
+
|
239
|
+
return closestField;
|
240
|
+
}
|
241
|
+
|
242
|
+
/**
|
243
|
+
* Show the appropriate drop indicator based on field and mouse position
|
244
|
+
* @param {HTMLElement} closestField - The closest field to drop on
|
245
|
+
* @param {MouseEvent} event - The mousemove event
|
246
|
+
*/
|
247
|
+
showDropIndicator(closestField, event) {
|
248
|
+
const rect = closestField.getBoundingClientRect();
|
249
|
+
const mouseX = event.clientX;
|
250
|
+
const mouseY = event.clientY;
|
251
|
+
const isHalfWidth = closestField.dataset.width === "half" && this.draggedField.dataset.width === "half";
|
252
|
+
|
253
|
+
// Handle paired fields (full width field over half width field)
|
254
|
+
if (this.handlePairedFieldsIndicator(closestField, mouseY)) {
|
255
|
+
return;
|
256
|
+
}
|
257
|
+
|
258
|
+
// Handle horizontal indicators for half-width fields
|
259
|
+
if (isHalfWidth && this.handleHorizontalIndicator(closestField, rect, mouseX, mouseY)) {
|
260
|
+
return;
|
261
|
+
}
|
262
|
+
|
263
|
+
// Handle vertical indicators (default case)
|
264
|
+
this.handleVerticalIndicator(closestField, rect, mouseY);
|
265
|
+
}
|
266
|
+
|
267
|
+
/**
|
268
|
+
* Handle drop indicators for paired fields
|
269
|
+
* @param {HTMLElement} closestField - The closest field
|
270
|
+
* @param {number} mouseY - The mouse Y position
|
271
|
+
* @returns {boolean} - True if handled, false otherwise
|
272
|
+
*/
|
273
|
+
handlePairedFieldsIndicator(closestField, mouseY) {
|
274
|
+
if (this.draggedField.dataset.width === "full" &&
|
275
|
+
closestField.dataset.width === "half" &&
|
276
|
+
closestField.dataset.rowGroup) {
|
277
|
+
|
278
|
+
const pairedField = this.fieldTargets.find(f =>
|
279
|
+
f !== closestField &&
|
280
|
+
f.dataset.rowGroup === closestField.dataset.rowGroup
|
281
|
+
);
|
282
|
+
|
283
|
+
if (pairedField) {
|
284
|
+
const closestRect = closestField.getBoundingClientRect();
|
285
|
+
const pairedRect = pairedField.getBoundingClientRect();
|
286
|
+
|
287
|
+
const rowRect = {
|
288
|
+
top: Math.min(closestRect.top, pairedRect.top),
|
289
|
+
bottom: Math.max(closestRect.bottom, pairedRect.bottom),
|
290
|
+
height: Math.max(closestRect.bottom, pairedRect.bottom) -
|
291
|
+
Math.min(closestRect.top, pairedRect.top)
|
292
|
+
};
|
293
|
+
|
294
|
+
const dropZone = document.createElement('div');
|
295
|
+
dropZone.className = 'drop-indicator absolute left-0 right-0 bg-gray-500 transition-all duration-200';
|
296
|
+
dropZone.style.height = '2px';
|
297
|
+
|
298
|
+
const rowCenterY = rowRect.top + (rowRect.height / 2);
|
299
|
+
if (mouseY < rowCenterY) {
|
300
|
+
dropZone.style.top = '0';
|
301
|
+
this.dropPosition = 'above';
|
302
|
+
} else {
|
303
|
+
dropZone.style.bottom = '0';
|
304
|
+
this.dropPosition = 'below';
|
305
|
+
}
|
306
|
+
|
307
|
+
closestField.classList.add('bg-gray-100');
|
308
|
+
pairedField.classList.add('bg-gray-100');
|
309
|
+
closestField.appendChild(dropZone);
|
310
|
+
this.dropTarget = closestField;
|
311
|
+
return true;
|
312
|
+
}
|
313
|
+
}
|
314
|
+
|
315
|
+
return false;
|
316
|
+
}
|
317
|
+
|
318
|
+
/**
|
319
|
+
* Handle horizontal drop indicators for half-width fields
|
320
|
+
* @param {HTMLElement} closestField - The closest field
|
321
|
+
* @param {DOMRect} rect - The bounding rectangle of the closest field
|
322
|
+
* @param {number} mouseX - The mouse X position
|
323
|
+
* @param {number} mouseY - The mouse Y position
|
324
|
+
* @returns {boolean} - True if handled, false otherwise
|
325
|
+
*/
|
326
|
+
handleHorizontalIndicator(closestField, rect, mouseX, mouseY) {
|
327
|
+
const { verticalThreshold } = this.constructor.config.dragDetection;
|
328
|
+
const verticalDistance = Math.abs(mouseY - (rect.top + rect.height/2));
|
329
|
+
|
330
|
+
if (verticalDistance < verticalThreshold) {
|
331
|
+
closestField.classList.add('bg-gray-100');
|
332
|
+
|
333
|
+
const dropZone = document.createElement('div');
|
334
|
+
dropZone.className = 'drop-indicator absolute inset-y-0 bg-gray-500 transition-all duration-200';
|
335
|
+
dropZone.style.width = '2px';
|
336
|
+
|
337
|
+
// Determine if we're dragging left-to-right or right-to-left
|
338
|
+
const isLeftToRight = this.originalLeft < rect.left;
|
339
|
+
|
340
|
+
if (isLeftToRight) {
|
341
|
+
// For left-to-right, always show the right indicator
|
342
|
+
dropZone.style.right = '0';
|
343
|
+
this.dropPosition = 'right';
|
344
|
+
} else {
|
345
|
+
// For right-to-left, use the standard approach
|
346
|
+
const mousePosition = mouseX - rect.left;
|
347
|
+
const isRight = mousePosition > rect.width / 2;
|
348
|
+
|
349
|
+
if (isRight) {
|
350
|
+
dropZone.style.right = '0';
|
351
|
+
this.dropPosition = 'right';
|
352
|
+
} else {
|
353
|
+
dropZone.style.left = '0';
|
354
|
+
this.dropPosition = 'left';
|
355
|
+
}
|
356
|
+
}
|
357
|
+
|
358
|
+
closestField.style.position = 'relative';
|
359
|
+
closestField.appendChild(dropZone);
|
360
|
+
this.dropTarget = closestField;
|
361
|
+
return true;
|
362
|
+
}
|
363
|
+
|
364
|
+
return false;
|
365
|
+
}
|
366
|
+
|
367
|
+
/**
|
368
|
+
* Handle vertical drop indicators
|
369
|
+
* @param {HTMLElement} closestField - The closest field
|
370
|
+
* @param {DOMRect} rect - The bounding rectangle of the closest field
|
371
|
+
* @param {number} mouseY - The mouse Y position
|
372
|
+
*/
|
373
|
+
handleVerticalIndicator(closestField, rect, mouseY) {
|
374
|
+
const dropZone = document.createElement('div');
|
375
|
+
dropZone.className = 'drop-indicator absolute left-0 right-0 bg-gray-500 transition-all duration-200';
|
376
|
+
dropZone.style.height = '2px';
|
377
|
+
|
378
|
+
const centerY = rect.top + (rect.height / 2);
|
379
|
+
if (mouseY < centerY) {
|
380
|
+
dropZone.style.top = '0';
|
381
|
+
this.dropPosition = 'above';
|
382
|
+
} else {
|
383
|
+
dropZone.style.bottom = '0';
|
384
|
+
this.dropPosition = 'below';
|
385
|
+
}
|
386
|
+
|
387
|
+
closestField.style.position = 'relative';
|
388
|
+
closestField.classList.add('bg-gray-100');
|
389
|
+
closestField.appendChild(dropZone);
|
390
|
+
this.dropTarget = closestField;
|
391
|
+
}
|
392
|
+
|
393
|
+
/**
|
394
|
+
* Handle the end of a drag operation
|
395
|
+
* @param {MouseEvent} event - The mouseup event
|
396
|
+
*/
|
397
|
+
handleDragEnd = (event) => {
|
398
|
+
if (!this.draggedField || !this.dropTarget) {
|
399
|
+
this.cleanupDrag();
|
400
|
+
return;
|
401
|
+
}
|
402
|
+
|
403
|
+
this.clearRowGroups();
|
404
|
+
|
405
|
+
if (this.isHorizontalDrop()) {
|
406
|
+
this.handleHorizontalDrop();
|
407
|
+
} else {
|
408
|
+
this.handleVerticalDrop();
|
409
|
+
}
|
410
|
+
|
411
|
+
this.cleanupDrag();
|
412
|
+
this.updateLayout();
|
413
|
+
this.collectAndSavePositions();
|
414
|
+
}
|
415
|
+
|
416
|
+
/**
|
417
|
+
* Clear existing row groups for the dragged field
|
418
|
+
*/
|
419
|
+
clearRowGroups() {
|
420
|
+
if (this.draggedField.dataset.rowGroup) {
|
421
|
+
const oldGroup = this.draggedField.dataset.rowGroup;
|
422
|
+
this.fieldTargets.forEach(field => {
|
423
|
+
if (field.dataset.rowGroup === oldGroup) {
|
424
|
+
field.dataset.rowGroup = '';
|
425
|
+
}
|
426
|
+
});
|
427
|
+
}
|
428
|
+
}
|
429
|
+
|
430
|
+
/**
|
431
|
+
* Check if the current drop is horizontal (left/right)
|
432
|
+
* @returns {boolean} - True if horizontal, false if vertical
|
433
|
+
*/
|
434
|
+
isHorizontalDrop() {
|
435
|
+
return (this.dropPosition === 'left' || this.dropPosition === 'right') &&
|
436
|
+
this.draggedField.dataset.width === 'half' &&
|
437
|
+
this.dropTarget.dataset.width === 'half';
|
438
|
+
}
|
439
|
+
|
440
|
+
/**
|
441
|
+
* Handle horizontal drop (side-by-side placement)
|
442
|
+
*/
|
443
|
+
handleHorizontalDrop() {
|
444
|
+
const newRowGroup = this.getNextRowGroup();
|
445
|
+
this.dropTarget.dataset.rowGroup = newRowGroup;
|
446
|
+
this.draggedField.dataset.rowGroup = newRowGroup;
|
447
|
+
|
448
|
+
if (this.dropPosition === 'right') {
|
449
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, this.dropTarget.nextSibling);
|
450
|
+
} else {
|
451
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, this.dropTarget);
|
452
|
+
}
|
453
|
+
}
|
454
|
+
|
455
|
+
/**
|
456
|
+
* Handle vertical drop (above/below placement)
|
457
|
+
*/
|
458
|
+
handleVerticalDrop() {
|
459
|
+
this.draggedField.dataset.rowGroup = '';
|
460
|
+
|
461
|
+
// If dropping near a paired row, move both fields together
|
462
|
+
if (this.dropTarget.dataset.width === 'half' && this.dropTarget.dataset.rowGroup) {
|
463
|
+
const pairedField = this.fieldTargets.find(f =>
|
464
|
+
f !== this.dropTarget &&
|
465
|
+
f.dataset.rowGroup === this.dropTarget.dataset.rowGroup
|
466
|
+
);
|
467
|
+
|
468
|
+
if (pairedField) {
|
469
|
+
if (this.dropPosition === 'above') {
|
470
|
+
// Move both fields above the dragged field
|
471
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, this.dropTarget);
|
472
|
+
} else {
|
473
|
+
// Move both fields below the dragged field
|
474
|
+
if (pairedField.nextSibling) {
|
475
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, pairedField.nextSibling);
|
476
|
+
} else {
|
477
|
+
this.dropTarget.parentNode.appendChild(this.draggedField);
|
478
|
+
}
|
479
|
+
}
|
480
|
+
}
|
481
|
+
} else {
|
482
|
+
// Normal vertical stacking
|
483
|
+
if (this.dropPosition === 'above') {
|
484
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, this.dropTarget);
|
485
|
+
} else {
|
486
|
+
this.dropTarget.parentNode.insertBefore(this.draggedField, this.dropTarget.nextSibling);
|
487
|
+
}
|
488
|
+
}
|
489
|
+
}
|
490
|
+
|
491
|
+
/**
|
492
|
+
* Clean up after drag operation
|
493
|
+
*/
|
494
|
+
cleanupDrag() {
|
495
|
+
// Restore dragged field
|
496
|
+
if (this.draggedField) {
|
497
|
+
this.draggedField.style.opacity = '1';
|
498
|
+
this.draggedField.style.transform = '';
|
499
|
+
}
|
500
|
+
|
501
|
+
// Remove ghost element
|
502
|
+
if (this.ghostElement) {
|
503
|
+
this.ghostElement.remove();
|
504
|
+
}
|
505
|
+
|
506
|
+
// Remove drop zone
|
507
|
+
if (this.dropZone) {
|
508
|
+
this.dropZone.remove();
|
509
|
+
}
|
510
|
+
|
511
|
+
// Remove all indicators and highlights but keep the base styles
|
512
|
+
this.fieldTargets.forEach(field => {
|
513
|
+
field.classList.remove(
|
514
|
+
'border-t-2', 'border-b-2', 'border-r-2', 'border-l-2',
|
515
|
+
'border-t-4', 'border-b-4',
|
516
|
+
'border-gray-500', 'border-dashed', 'bg-gray-100'
|
517
|
+
);
|
518
|
+
|
519
|
+
// Add back the base background if it was removed
|
520
|
+
field.classList.add('bg-gray-50');
|
521
|
+
|
522
|
+
// Remove any leftover drop zones
|
523
|
+
const dropZones = field.querySelectorAll('.absolute');
|
524
|
+
dropZones.forEach(zone => zone.remove());
|
525
|
+
});
|
526
|
+
|
527
|
+
// Reset drag state
|
528
|
+
this.resetDragState();
|
529
|
+
|
530
|
+
// Remove event listeners
|
531
|
+
document.removeEventListener('mousemove', this.handleDragMove);
|
532
|
+
document.removeEventListener('mouseup', this.handleDragEnd);
|
533
|
+
}
|
534
|
+
|
535
|
+
/**
|
536
|
+
* Get the next available row group number
|
537
|
+
* @returns {string} - The next row group number as a string
|
538
|
+
*/
|
539
|
+
getNextRowGroup() {
|
540
|
+
let maxGroup = 0;
|
541
|
+
this.fieldTargets.forEach(field => {
|
542
|
+
const group = parseInt(field.dataset.rowGroup);
|
543
|
+
if (!isNaN(group) && group > maxGroup) {
|
544
|
+
maxGroup = group;
|
545
|
+
}
|
546
|
+
});
|
547
|
+
return (maxGroup + 1).toString();
|
548
|
+
}
|
549
|
+
|
550
|
+
/**
|
551
|
+
* Update the layout of fields based on their width and row group
|
552
|
+
*/
|
553
|
+
updateLayout() {
|
554
|
+
let currentRowGroup = null;
|
555
|
+
const { padding } = this.constructor.config.styling;
|
556
|
+
|
557
|
+
this.fieldTargets.forEach(field => {
|
558
|
+
// Reset field styles
|
559
|
+
field.style.gridColumn = "1 / -1";
|
560
|
+
field.style.position = "relative";
|
561
|
+
field.classList.remove("border-l-2", "border-dashed", "border-gray-300");
|
562
|
+
|
563
|
+
// Set consistent padding for all fields
|
564
|
+
field.style.padding = padding;
|
565
|
+
field.style.margin = "0";
|
566
|
+
field.style.marginTop = padding;
|
567
|
+
|
568
|
+
const width = field.dataset.width;
|
569
|
+
const rowGroup = field.dataset.rowGroup;
|
570
|
+
|
571
|
+
// Handle half-width fields
|
572
|
+
if (width === "half") {
|
573
|
+
if (rowGroup) {
|
574
|
+
if (currentRowGroup !== rowGroup) {
|
575
|
+
// First field in the row group
|
576
|
+
currentRowGroup = rowGroup;
|
577
|
+
field.style.gridColumn = "1 / 2";
|
578
|
+
} else if (currentRowGroup === rowGroup) {
|
579
|
+
// Second field in the row group
|
580
|
+
field.style.gridColumn = "2 / 3";
|
581
|
+
field.classList.add("border-l-2", "border-dashed", "border-gray-300");
|
582
|
+
currentRowGroup = null;
|
583
|
+
}
|
584
|
+
} else {
|
585
|
+
// Half-width field without a row group
|
586
|
+
field.style.gridColumn = "1 / 2";
|
587
|
+
}
|
588
|
+
}
|
589
|
+
});
|
590
|
+
}
|
591
|
+
|
592
|
+
/**
|
593
|
+
* Toggle the width of a field between full and half
|
594
|
+
* @param {Event} event - The click event
|
595
|
+
*/
|
596
|
+
toggleWidth(event) {
|
597
|
+
event.preventDefault();
|
598
|
+
|
599
|
+
// Find the button and then the field
|
600
|
+
const button = event.currentTarget;
|
601
|
+
const field = button.closest("[data-field-layout-target='field']");
|
602
|
+
|
603
|
+
if (!field) return;
|
604
|
+
|
605
|
+
const fieldId = field.dataset.fieldId;
|
606
|
+
const currentWidth = field.dataset.width;
|
607
|
+
const newWidth = currentWidth === "full" ? "half" : "full";
|
608
|
+
|
609
|
+
// Update the field's width in the database
|
610
|
+
this.updateFieldWidth(fieldId, newWidth)
|
611
|
+
.catch(error => {
|
612
|
+
console.error("Error toggling width:", error);
|
613
|
+
});
|
614
|
+
}
|
615
|
+
|
616
|
+
/**
|
617
|
+
* Update a field's width in the database
|
618
|
+
* @param {string} fieldId - The ID of the field to update
|
619
|
+
* @param {string} width - The new width ('full' or 'half')
|
620
|
+
* @returns {Promise} - A promise that resolves when the update is complete
|
621
|
+
*/
|
622
|
+
async updateFieldWidth(fieldId, width) {
|
623
|
+
const field = this.fieldTargets.find(f => f.dataset.fieldId === fieldId);
|
624
|
+
if (!field) return;
|
625
|
+
|
626
|
+
const updatePath = field.dataset.updateWidthPath;
|
627
|
+
|
628
|
+
try {
|
629
|
+
const response = await fetch(updatePath, {
|
630
|
+
method: 'PATCH',
|
631
|
+
headers: {
|
632
|
+
'Content-Type': 'application/json',
|
633
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
634
|
+
},
|
635
|
+
body: JSON.stringify({ width })
|
636
|
+
});
|
637
|
+
|
638
|
+
if (!response.ok) {
|
639
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
640
|
+
}
|
641
|
+
|
642
|
+
const data = await response.json();
|
643
|
+
|
644
|
+
// Update the field's data attribute
|
645
|
+
field.dataset.width = width;
|
646
|
+
|
647
|
+
// Update the button's icon
|
648
|
+
const button = field.querySelector('.toggle-width-button');
|
649
|
+
if (button) {
|
650
|
+
const icon = button.querySelector('svg');
|
651
|
+
if (icon) {
|
652
|
+
if (width === 'full') {
|
653
|
+
// Show 'shrink' icon when field is full (click to go half)
|
654
|
+
icon.innerHTML = '<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8"/><path d="M9 19.8V15m0 0H4.2M9 15l-6 6"/><path d="M15 9l6-6m-6 6V4.2m0 4.8h4.8"/><path d="M9 4.2V9m0 0H4.2M9 9 3 3"/>';
|
655
|
+
button.setAttribute('title', 'Shrink to half width');
|
656
|
+
} else {
|
657
|
+
// Show 'expand' icon when field is half (click to go full)
|
658
|
+
icon.innerHTML = '<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8"/><path d="M3 16.2V21m0 0h4.8M3 21l6-6"/><path d="M21 7.8V3m0 0h-4.8M21 3l-6 6"/><path d="M3 7.8V3m0 0h4.8M3 3l6 6"/>';
|
659
|
+
button.setAttribute('title', 'Expand to full width');
|
660
|
+
}
|
661
|
+
}
|
662
|
+
}
|
663
|
+
|
664
|
+
// Update the layout
|
665
|
+
this.updateLayout();
|
666
|
+
|
667
|
+
// Update positions to ensure group positions are correct
|
668
|
+
this.handleDragEnd();
|
669
|
+
|
670
|
+
return data;
|
671
|
+
} catch (error) {
|
672
|
+
throw error;
|
673
|
+
}
|
674
|
+
}
|
675
|
+
|
676
|
+
/**
|
677
|
+
* Collect field positions and save them to the server
|
678
|
+
*/
|
679
|
+
collectAndSavePositions() {
|
680
|
+
// Collect positions and additional data
|
681
|
+
const positions = this.fieldTargets.map((field, index) => ({
|
682
|
+
id: field.dataset.fieldId,
|
683
|
+
position: index + 1,
|
684
|
+
row_group: field.dataset.rowGroup || null,
|
685
|
+
width: field.dataset.width
|
686
|
+
}));
|
687
|
+
|
688
|
+
// Send the positions to the server
|
689
|
+
fetch(this.element.dataset.updatePositionsPath, {
|
690
|
+
method: 'POST',
|
691
|
+
headers: {
|
692
|
+
'Content-Type': 'application/json',
|
693
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
694
|
+
},
|
695
|
+
body: JSON.stringify({ positions })
|
696
|
+
})
|
697
|
+
.then(response => {
|
698
|
+
if (!response.ok) {
|
699
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
700
|
+
}
|
701
|
+
return response.headers.get("content-type")?.includes("application/json")
|
702
|
+
? response.json()
|
703
|
+
: { success: true };
|
704
|
+
})
|
705
|
+
.catch((error) => {
|
706
|
+
console.error('Error updating positions:', error);
|
707
|
+
});
|
708
|
+
}
|
709
|
+
}
|