@ogidor/dashboard 1.0.2 → 1.0.4
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.
- package/README.md +283 -48
- package/app/app.module.d.ts +7 -9
- package/app/custom-grid.component.d.ts +93 -0
- package/app/dashboard-state.service.d.ts +54 -33
- package/app/dashboard.component.d.ts +27 -59
- package/app/models.d.ts +18 -38
- package/app/widget-renderer.component.d.ts +5 -32
- package/esm2020/app/app.module.mjs +21 -29
- package/esm2020/app/custom-grid.component.mjs +509 -0
- package/esm2020/app/dashboard-state.service.mjs +228 -158
- package/esm2020/app/dashboard.component.mjs +198 -602
- package/esm2020/app/models.mjs +1 -1
- package/esm2020/app/widget-renderer.component.mjs +41 -121
- package/esm2020/public-api.mjs +5 -4
- package/fesm2015/ogidor-dashboard.mjs +988 -909
- package/fesm2015/ogidor-dashboard.mjs.map +1 -1
- package/fesm2020/ogidor-dashboard.mjs +982 -898
- package/fesm2020/ogidor-dashboard.mjs.map +1 -1
- package/package.json +8 -12
- package/public-api.d.ts +3 -2
|
@@ -1,433 +1,910 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Directive, EventEmitter, TemplateRef, Component, ChangeDetectionStrategy, Input, Output, ViewChild, ContentChild, Injectable, NgModule } from '@angular/core';
|
|
3
|
+
import { BrowserModule } from '@angular/platform-browser';
|
|
4
4
|
import * as i2 from '@angular/common';
|
|
5
5
|
import { CommonModule } from '@angular/common';
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
import { BehaviorSubject, Subscription } from 'rxjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directive applied to the template that should be stamped inside each grid cell.
|
|
10
|
+
* Usage:
|
|
11
|
+
* <app-grid [widgets]="...">
|
|
12
|
+
* <ng-template gridCell let-widget="widget">
|
|
13
|
+
* <app-widget-renderer [widget]="widget" ...></app-widget-renderer>
|
|
14
|
+
* </ng-template>
|
|
15
|
+
* </app-grid>
|
|
16
|
+
*/
|
|
17
|
+
class GridCellDirective {
|
|
18
|
+
constructor(templateRef) {
|
|
19
|
+
this.templateRef = templateRef;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
GridCellDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: GridCellDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
|
|
23
|
+
GridCellDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "15.2.10", type: GridCellDirective, selector: "[gridCell]", ngImport: i0 });
|
|
24
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: GridCellDirective, decorators: [{
|
|
25
|
+
type: Directive,
|
|
26
|
+
args: [{ selector: '[gridCell]' }]
|
|
27
|
+
}], ctorParameters: function () { return [{ type: i0.TemplateRef }]; } });
|
|
28
|
+
class GridEngine {
|
|
29
|
+
/**
|
|
30
|
+
* Check if two rects overlap.
|
|
31
|
+
*/
|
|
32
|
+
static collides(a, b) {
|
|
33
|
+
return !(a.x + a.cols <= b.x ||
|
|
34
|
+
b.x + b.cols <= a.x ||
|
|
35
|
+
a.y + a.rows <= b.y ||
|
|
36
|
+
b.y + b.rows <= a.y);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get ALL widgets that collide with `rect`.
|
|
40
|
+
*/
|
|
41
|
+
static getAllCollisions(widgets, rect) {
|
|
42
|
+
return widgets.filter(w => w.id !== rect.id && GridEngine.collides(w, rect));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Sort widgets top-to-bottom, left-to-right.
|
|
46
|
+
*/
|
|
47
|
+
static sortByPosition(widgets) {
|
|
48
|
+
return [...widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Move a widget to (x, y) and push any colliding widgets downward.
|
|
52
|
+
* Returns the full list with resolved positions.
|
|
53
|
+
*/
|
|
54
|
+
static moveWidget(allWidgets, widget, newX, newY, columns) {
|
|
55
|
+
const widgets = allWidgets.map(w => ({ ...w }));
|
|
56
|
+
const moving = widgets.find(w => w.id === widget.id);
|
|
57
|
+
if (!moving)
|
|
58
|
+
return widgets;
|
|
59
|
+
moving.x = Math.max(0, Math.min(newX, columns - moving.cols));
|
|
60
|
+
moving.y = Math.max(0, newY);
|
|
61
|
+
const sorted = GridEngine.sortByPosition(widgets);
|
|
62
|
+
GridEngine.resolveCollisions(sorted, moving);
|
|
63
|
+
return GridEngine.compact(sorted, columns);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resize a widget and push colliding widgets down.
|
|
67
|
+
*/
|
|
68
|
+
static resizeWidget(allWidgets, widget, newCols, newRows, columns) {
|
|
69
|
+
const widgets = allWidgets.map(w => ({ ...w }));
|
|
70
|
+
const resizing = widgets.find(w => w.id === widget.id);
|
|
71
|
+
if (!resizing)
|
|
72
|
+
return widgets;
|
|
73
|
+
resizing.cols = Math.max(1, Math.min(newCols, columns - resizing.x));
|
|
74
|
+
resizing.rows = Math.max(1, newRows);
|
|
75
|
+
const sorted = GridEngine.sortByPosition(widgets);
|
|
76
|
+
GridEngine.resolveCollisions(sorted, resizing);
|
|
77
|
+
return GridEngine.compact(sorted, columns);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Push all widgets that collide with `movedWidget` downward, recursively.
|
|
81
|
+
*/
|
|
82
|
+
static resolveCollisions(widgets, movedWidget) {
|
|
83
|
+
const collisions = GridEngine.getAllCollisions(widgets, movedWidget);
|
|
84
|
+
for (const collider of collisions) {
|
|
85
|
+
collider.y = movedWidget.y + movedWidget.rows;
|
|
86
|
+
GridEngine.resolveCollisions(widgets, collider);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Compact the grid: move every widget as far up as possible without overlapping.
|
|
91
|
+
*/
|
|
92
|
+
static compact(widgets, _columns) {
|
|
93
|
+
const sorted = GridEngine.sortByPosition(widgets);
|
|
94
|
+
const placed = [];
|
|
95
|
+
for (const widget of sorted) {
|
|
96
|
+
widget.y = GridEngine.findCompactY(placed, widget);
|
|
97
|
+
placed.push(widget);
|
|
98
|
+
}
|
|
99
|
+
return placed;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Find the highest Y position a widget can occupy without overlapping any already-placed widget.
|
|
103
|
+
*/
|
|
104
|
+
static findCompactY(placed, widget) {
|
|
105
|
+
let y = 0;
|
|
106
|
+
while (true) {
|
|
107
|
+
const test = { ...widget, y };
|
|
108
|
+
const collision = placed.find(p => GridEngine.collides(p, test));
|
|
109
|
+
if (!collision)
|
|
110
|
+
return y;
|
|
111
|
+
y = collision.y + collision.rows;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Compute the total number of rows the grid needs.
|
|
116
|
+
*/
|
|
117
|
+
static getGridHeight(widgets) {
|
|
118
|
+
if (widgets.length === 0)
|
|
119
|
+
return 0;
|
|
120
|
+
return Math.max(...widgets.map(w => w.y + w.rows));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/* ═══════════════════════════════════════════════════════════════════════
|
|
124
|
+
CUSTOM GRID COMPONENT
|
|
125
|
+
═══════════════════════════════════════════════════════════════════════ */
|
|
126
|
+
class CustomGridComponent {
|
|
127
|
+
constructor(zone, cdr) {
|
|
128
|
+
this.zone = zone;
|
|
129
|
+
this.cdr = cdr;
|
|
130
|
+
this.widgets = [];
|
|
131
|
+
this.columns = 12;
|
|
132
|
+
this.gap = 16;
|
|
133
|
+
this.rowHeight = 80;
|
|
134
|
+
this.minItemCols = 1;
|
|
135
|
+
this.minItemRows = 1;
|
|
136
|
+
this.itemChanged = new EventEmitter();
|
|
137
|
+
this.layoutChanged = new EventEmitter();
|
|
138
|
+
this.placeholder = null;
|
|
139
|
+
this.containerHeight = 400;
|
|
140
|
+
this.dragging = null;
|
|
141
|
+
this.resizing = null;
|
|
142
|
+
this.previewWidgets = [];
|
|
143
|
+
this.boundMouseMove = this.onMouseMove.bind(this);
|
|
144
|
+
this.boundMouseUp = this.onMouseUp.bind(this);
|
|
145
|
+
}
|
|
146
|
+
ngOnInit() {
|
|
147
|
+
this.compactAndApply();
|
|
148
|
+
this.updateContainerHeight();
|
|
149
|
+
this.zone.runOutsideAngular(() => {
|
|
150
|
+
window.addEventListener('mousemove', this.boundMouseMove);
|
|
151
|
+
window.addEventListener('mouseup', this.boundMouseUp);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
ngOnChanges(_changes) {
|
|
155
|
+
this.updateContainerHeight();
|
|
156
|
+
}
|
|
157
|
+
ngOnDestroy() {
|
|
158
|
+
window.removeEventListener('mousemove', this.boundMouseMove);
|
|
159
|
+
window.removeEventListener('mouseup', this.boundMouseUp);
|
|
160
|
+
}
|
|
161
|
+
trackByFn(_index, item) { return item.id; }
|
|
162
|
+
// ── Geometry ──
|
|
163
|
+
get cellWidth() {
|
|
164
|
+
const containerW = this.gridContainer?.nativeElement?.clientWidth ?? 800;
|
|
165
|
+
return (containerW - (this.columns - 1) * this.gap) / this.columns;
|
|
166
|
+
}
|
|
167
|
+
colToPixel(col) {
|
|
168
|
+
return col * (this.cellWidth + this.gap);
|
|
169
|
+
}
|
|
170
|
+
rowToPixel(row) {
|
|
171
|
+
return row * (this.rowHeight + this.gap);
|
|
172
|
+
}
|
|
173
|
+
pixelToCol(px) {
|
|
174
|
+
return Math.round(px / (this.cellWidth + this.gap));
|
|
175
|
+
}
|
|
176
|
+
pixelToRow(px) {
|
|
177
|
+
return Math.round(px / (this.rowHeight + this.gap));
|
|
178
|
+
}
|
|
179
|
+
colsToPixelWidth(cols) {
|
|
180
|
+
return cols * this.cellWidth + (cols - 1) * this.gap;
|
|
181
|
+
}
|
|
182
|
+
rowsToPixelHeight(rows) {
|
|
183
|
+
return rows * this.rowHeight + (rows - 1) * this.gap;
|
|
184
|
+
}
|
|
185
|
+
getItemLeft(w) {
|
|
186
|
+
return this.dragging?.id === w.id ? this.dragging.currentPixelX : this.colToPixel(w.x);
|
|
187
|
+
}
|
|
188
|
+
getItemTop(w) {
|
|
189
|
+
return this.dragging?.id === w.id ? this.dragging.currentPixelY : this.rowToPixel(w.y);
|
|
190
|
+
}
|
|
191
|
+
getItemWidth(w) {
|
|
192
|
+
return this.resizing?.id === w.id ? this.resizing.currentPixelW : this.colsToPixelWidth(w.cols);
|
|
193
|
+
}
|
|
194
|
+
getItemHeight(w) {
|
|
195
|
+
return this.resizing?.id === w.id ? this.resizing.currentPixelH : this.rowsToPixelHeight(w.rows);
|
|
196
|
+
}
|
|
197
|
+
// ── Drag ──
|
|
198
|
+
onDragStart(event, widget) {
|
|
199
|
+
const target = event.target;
|
|
200
|
+
if (target.closest('button, input, select, textarea, a, .resize-handle'))
|
|
201
|
+
return;
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
event.stopPropagation();
|
|
204
|
+
const rect = this.gridContainer.nativeElement.getBoundingClientRect();
|
|
205
|
+
const itemLeft = this.colToPixel(widget.x);
|
|
206
|
+
const itemTop = this.rowToPixel(widget.y);
|
|
207
|
+
this.dragging = {
|
|
208
|
+
id: widget.id,
|
|
209
|
+
offsetX: event.clientX - rect.left - itemLeft,
|
|
210
|
+
offsetY: event.clientY - rect.top - itemTop,
|
|
211
|
+
currentPixelX: itemLeft,
|
|
212
|
+
currentPixelY: itemTop,
|
|
213
|
+
};
|
|
214
|
+
this.previewWidgets = this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows }));
|
|
215
|
+
this.placeholder = {
|
|
216
|
+
left: itemLeft, top: itemTop,
|
|
217
|
+
width: this.colsToPixelWidth(widget.cols),
|
|
218
|
+
height: this.rowsToPixelHeight(widget.rows),
|
|
219
|
+
};
|
|
220
|
+
this.cdr.markForCheck();
|
|
221
|
+
}
|
|
222
|
+
onResizeStart(event, widget) {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
event.stopPropagation();
|
|
225
|
+
this.resizing = {
|
|
226
|
+
id: widget.id,
|
|
227
|
+
startMouseX: event.clientX,
|
|
228
|
+
startMouseY: event.clientY,
|
|
229
|
+
startCols: widget.cols,
|
|
230
|
+
startRows: widget.rows,
|
|
231
|
+
currentPixelW: this.colsToPixelWidth(widget.cols),
|
|
232
|
+
currentPixelH: this.rowsToPixelHeight(widget.rows),
|
|
233
|
+
};
|
|
234
|
+
this.previewWidgets = this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows }));
|
|
235
|
+
this.placeholder = {
|
|
236
|
+
left: this.colToPixel(widget.x), top: this.rowToPixel(widget.y),
|
|
237
|
+
width: this.colsToPixelWidth(widget.cols),
|
|
238
|
+
height: this.rowsToPixelHeight(widget.rows),
|
|
239
|
+
};
|
|
240
|
+
this.cdr.markForCheck();
|
|
241
|
+
}
|
|
242
|
+
onMouseMove(event) {
|
|
243
|
+
if (this.dragging)
|
|
244
|
+
this.handleDragMove(event);
|
|
245
|
+
else if (this.resizing)
|
|
246
|
+
this.handleResizeMove(event);
|
|
247
|
+
}
|
|
248
|
+
onMouseUp(_event) {
|
|
249
|
+
if (this.dragging)
|
|
250
|
+
this.zone.run(() => this.finalizeDrag());
|
|
251
|
+
else if (this.resizing)
|
|
252
|
+
this.zone.run(() => this.finalizeResize());
|
|
253
|
+
}
|
|
254
|
+
handleDragMove(event) {
|
|
255
|
+
if (!this.dragging)
|
|
256
|
+
return;
|
|
257
|
+
const rect = this.gridContainer.nativeElement.getBoundingClientRect();
|
|
258
|
+
const widget = this.widgets.find(w => w.id === this.dragging.id);
|
|
259
|
+
if (!widget)
|
|
260
|
+
return;
|
|
261
|
+
let px = event.clientX - rect.left - this.dragging.offsetX;
|
|
262
|
+
let py = event.clientY - rect.top - this.dragging.offsetY;
|
|
263
|
+
px = Math.max(0, Math.min(px, rect.width - this.colsToPixelWidth(widget.cols)));
|
|
264
|
+
py = Math.max(0, py);
|
|
265
|
+
this.dragging.currentPixelX = px;
|
|
266
|
+
this.dragging.currentPixelY = py;
|
|
267
|
+
const targetCol = Math.max(0, Math.min(this.pixelToCol(px), this.columns - widget.cols));
|
|
268
|
+
const targetRow = Math.max(0, this.pixelToRow(py));
|
|
269
|
+
const resolved = GridEngine.moveWidget(this.previewWidgets, { id: widget.id, x: widget.x, y: widget.y, cols: widget.cols, rows: widget.rows }, targetCol, targetRow, this.columns);
|
|
270
|
+
this.placeholder = {
|
|
271
|
+
left: this.colToPixel(targetCol), top: this.rowToPixel(targetRow),
|
|
272
|
+
width: this.colsToPixelWidth(widget.cols),
|
|
273
|
+
height: this.rowsToPixelHeight(widget.rows),
|
|
274
|
+
};
|
|
275
|
+
this.zone.run(() => {
|
|
276
|
+
for (const rw of resolved) {
|
|
277
|
+
if (rw.id === widget.id)
|
|
278
|
+
continue;
|
|
279
|
+
const w = this.widgets.find(ww => ww.id === rw.id);
|
|
280
|
+
if (w) {
|
|
281
|
+
w.x = rw.x;
|
|
282
|
+
w.y = rw.y;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.updateContainerHeight();
|
|
286
|
+
this.cdr.markForCheck();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
finalizeDrag() {
|
|
290
|
+
if (!this.dragging)
|
|
291
|
+
return;
|
|
292
|
+
const widget = this.widgets.find(w => w.id === this.dragging.id);
|
|
293
|
+
const targetCol = Math.max(0, Math.min(this.pixelToCol(this.dragging.currentPixelX), this.columns - widget.cols));
|
|
294
|
+
const targetRow = Math.max(0, this.pixelToRow(this.dragging.currentPixelY));
|
|
295
|
+
const resolved = GridEngine.moveWidget(this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows })), { id: widget.id, x: widget.x, y: widget.y, cols: widget.cols, rows: widget.rows }, targetCol, targetRow, this.columns);
|
|
296
|
+
for (const rw of resolved) {
|
|
297
|
+
const w = this.widgets.find(ww => ww.id === rw.id);
|
|
298
|
+
if (w) {
|
|
299
|
+
w.x = rw.x;
|
|
300
|
+
w.y = rw.y;
|
|
301
|
+
w.cols = rw.cols;
|
|
302
|
+
w.rows = rw.rows;
|
|
303
|
+
this.itemChanged.emit(w);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.dragging = null;
|
|
307
|
+
this.placeholder = null;
|
|
308
|
+
this.previewWidgets = [];
|
|
309
|
+
this.updateContainerHeight();
|
|
310
|
+
this.layoutChanged.emit(this.widgets);
|
|
311
|
+
this.cdr.markForCheck();
|
|
312
|
+
}
|
|
313
|
+
handleResizeMove(event) {
|
|
314
|
+
if (!this.resizing)
|
|
315
|
+
return;
|
|
316
|
+
const widget = this.widgets.find(w => w.id === this.resizing.id);
|
|
317
|
+
if (!widget)
|
|
318
|
+
return;
|
|
319
|
+
const dx = event.clientX - this.resizing.startMouseX;
|
|
320
|
+
const dy = event.clientY - this.resizing.startMouseY;
|
|
321
|
+
let newCols = Math.max(this.minItemCols, Math.round((this.colsToPixelWidth(this.resizing.startCols) + dx) / (this.cellWidth + this.gap)));
|
|
322
|
+
let newRows = Math.max(this.minItemRows, Math.round((this.rowsToPixelHeight(this.resizing.startRows) + dy) / (this.rowHeight + this.gap)));
|
|
323
|
+
newCols = Math.min(newCols, this.columns - widget.x);
|
|
324
|
+
this.resizing.currentPixelW = this.colsToPixelWidth(newCols);
|
|
325
|
+
this.resizing.currentPixelH = this.rowsToPixelHeight(newRows);
|
|
326
|
+
const resolved = GridEngine.resizeWidget(this.previewWidgets, { id: widget.id, x: widget.x, y: widget.y, cols: widget.cols, rows: widget.rows }, newCols, newRows, this.columns);
|
|
327
|
+
this.placeholder = {
|
|
328
|
+
left: this.colToPixel(widget.x), top: this.rowToPixel(widget.y),
|
|
329
|
+
width: this.colsToPixelWidth(newCols),
|
|
330
|
+
height: this.rowsToPixelHeight(newRows),
|
|
331
|
+
};
|
|
332
|
+
this.zone.run(() => {
|
|
333
|
+
for (const rw of resolved) {
|
|
334
|
+
if (rw.id === widget.id)
|
|
335
|
+
continue;
|
|
336
|
+
const w = this.widgets.find(ww => ww.id === rw.id);
|
|
337
|
+
if (w) {
|
|
338
|
+
w.x = rw.x;
|
|
339
|
+
w.y = rw.y;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
this.updateContainerHeight();
|
|
343
|
+
this.cdr.markForCheck();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
finalizeResize() {
|
|
347
|
+
if (!this.resizing)
|
|
348
|
+
return;
|
|
349
|
+
const widget = this.widgets.find(w => w.id === this.resizing.id);
|
|
350
|
+
let newCols = Math.max(this.minItemCols, Math.round(this.resizing.currentPixelW / (this.cellWidth + this.gap)));
|
|
351
|
+
let newRows = Math.max(this.minItemRows, Math.round(this.resizing.currentPixelH / (this.rowHeight + this.gap)));
|
|
352
|
+
newCols = Math.min(newCols, this.columns - widget.x);
|
|
353
|
+
const resolved = GridEngine.resizeWidget(this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows })), { id: widget.id, x: widget.x, y: widget.y, cols: widget.cols, rows: widget.rows }, newCols, newRows, this.columns);
|
|
354
|
+
for (const rw of resolved) {
|
|
355
|
+
const w = this.widgets.find(ww => ww.id === rw.id);
|
|
356
|
+
if (w) {
|
|
357
|
+
w.x = rw.x;
|
|
358
|
+
w.y = rw.y;
|
|
359
|
+
w.cols = rw.cols;
|
|
360
|
+
w.rows = rw.rows;
|
|
361
|
+
this.itemChanged.emit(w);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.resizing = null;
|
|
365
|
+
this.placeholder = null;
|
|
366
|
+
this.previewWidgets = [];
|
|
367
|
+
this.updateContainerHeight();
|
|
368
|
+
this.layoutChanged.emit(this.widgets);
|
|
369
|
+
this.cdr.markForCheck();
|
|
370
|
+
}
|
|
371
|
+
// ── Utilities ──
|
|
372
|
+
compactAndApply() {
|
|
373
|
+
if (!this.widgets?.length)
|
|
374
|
+
return;
|
|
375
|
+
const compacted = GridEngine.compact(this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows })), this.columns);
|
|
376
|
+
for (const rw of compacted) {
|
|
377
|
+
const w = this.widgets.find(ww => ww.id === rw.id);
|
|
378
|
+
if (w) {
|
|
379
|
+
w.x = rw.x;
|
|
380
|
+
w.y = rw.y;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
updateContainerHeight() {
|
|
385
|
+
if (!this.widgets?.length) {
|
|
386
|
+
this.containerHeight = 400;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const maxRow = GridEngine.getGridHeight(this.widgets.map(w => ({ id: w.id, x: w.x, y: w.y, cols: w.cols, rows: w.rows })));
|
|
390
|
+
this.containerHeight = this.rowToPixel(maxRow) + this.rowHeight + this.gap;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
CustomGridComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: CustomGridComponent, deps: [{ token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
394
|
+
CustomGridComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.10", type: CustomGridComponent, selector: "app-grid", inputs: { widgets: "widgets", columns: "columns", gap: "gap", rowHeight: "rowHeight", minItemCols: "minItemCols", minItemRows: "minItemRows" }, outputs: { itemChanged: "itemChanged", layoutChanged: "layoutChanged" }, queries: [{ propertyName: "cellTemplate", first: true, predicate: GridCellDirective, descendants: true, read: TemplateRef }], viewQueries: [{ propertyName: "gridContainer", first: true, predicate: ["gridContainer"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `
|
|
395
|
+
<div
|
|
396
|
+
#gridContainer
|
|
397
|
+
class="grid-container"
|
|
398
|
+
[style.height.px]="containerHeight"
|
|
399
|
+
>
|
|
400
|
+
<!-- Placeholder preview -->
|
|
401
|
+
<div
|
|
402
|
+
class="grid-placeholder"
|
|
403
|
+
*ngIf="placeholder"
|
|
404
|
+
[style.left.px]="placeholder.left"
|
|
405
|
+
[style.top.px]="placeholder.top"
|
|
406
|
+
[style.width.px]="placeholder.width"
|
|
407
|
+
[style.height.px]="placeholder.height"
|
|
408
|
+
></div>
|
|
409
|
+
|
|
410
|
+
<!-- Widget items -->
|
|
411
|
+
<div
|
|
412
|
+
*ngFor="let widget of widgets; trackBy: trackByFn"
|
|
413
|
+
class="grid-item"
|
|
414
|
+
[class.is-dragging]="dragging?.id === widget.id"
|
|
415
|
+
[class.is-resizing]="resizing?.id === widget.id"
|
|
416
|
+
[style.left.px]="getItemLeft(widget)"
|
|
417
|
+
[style.top.px]="getItemTop(widget)"
|
|
418
|
+
[style.width.px]="getItemWidth(widget)"
|
|
419
|
+
[style.height.px]="getItemHeight(widget)"
|
|
420
|
+
[style.z-index]="(dragging?.id === widget.id || resizing?.id === widget.id) ? 100 : 1"
|
|
421
|
+
(mousedown)="onDragStart($event, widget)"
|
|
422
|
+
>
|
|
423
|
+
<!-- Content stamped from parent template, context carries the widget -->
|
|
424
|
+
<ng-container
|
|
425
|
+
*ngIf="cellTemplate"
|
|
426
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
427
|
+
[ngTemplateOutletContext]="{ widget: widget }"
|
|
428
|
+
></ng-container>
|
|
429
|
+
|
|
430
|
+
<!-- Resize handle -->
|
|
431
|
+
<div class="resize-handle" (mousedown)="onResizeStart($event, widget)">
|
|
432
|
+
<svg width="12" height="12" viewBox="0 0 12 12">
|
|
433
|
+
<path d="M11 1v10H1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
434
|
+
<path d="M11 5v6H5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
435
|
+
<path d="M11 9v2H9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
436
|
+
</svg>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
`, isInline: true, styles: [":host{display:block;width:100%;height:100%}.grid-container{position:relative;width:100%;min-height:100%;transition:height .25s ease}.grid-placeholder{position:absolute;background:var(--dash-accent-color, #0a84ff);opacity:.12;border-radius:24px;border:2px dashed var(--dash-accent-color, #0a84ff);transition:left .15s ease,top .15s ease,width .15s ease,height .15s ease;pointer-events:none;z-index:0}.grid-item{position:absolute;display:flex;flex-direction:column;transition:left .25s ease,top .25s ease,width .25s ease,height .25s ease;border-radius:24px;overflow:hidden}.grid-item.is-dragging,.grid-item.is-resizing{transition:none!important;opacity:.88;overflow:visible;filter:drop-shadow(0 20px 40px rgba(0,0,0,.45))}.resize-handle{position:absolute;right:4px;bottom:4px;width:22px;height:22px;cursor:nwse-resize;display:flex;align-items:center;justify-content:center;color:#ffffff40;border-radius:6px;transition:color .2s,background .2s;z-index:20}.resize-handle:hover{color:var(--dash-accent-color, #0a84ff);background:rgba(255,255,255,.06)}\n"], dependencies: [{ kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
441
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: CustomGridComponent, decorators: [{
|
|
442
|
+
type: Component,
|
|
443
|
+
args: [{ selector: 'app-grid', template: `
|
|
444
|
+
<div
|
|
445
|
+
#gridContainer
|
|
446
|
+
class="grid-container"
|
|
447
|
+
[style.height.px]="containerHeight"
|
|
448
|
+
>
|
|
449
|
+
<!-- Placeholder preview -->
|
|
450
|
+
<div
|
|
451
|
+
class="grid-placeholder"
|
|
452
|
+
*ngIf="placeholder"
|
|
453
|
+
[style.left.px]="placeholder.left"
|
|
454
|
+
[style.top.px]="placeholder.top"
|
|
455
|
+
[style.width.px]="placeholder.width"
|
|
456
|
+
[style.height.px]="placeholder.height"
|
|
457
|
+
></div>
|
|
458
|
+
|
|
459
|
+
<!-- Widget items -->
|
|
460
|
+
<div
|
|
461
|
+
*ngFor="let widget of widgets; trackBy: trackByFn"
|
|
462
|
+
class="grid-item"
|
|
463
|
+
[class.is-dragging]="dragging?.id === widget.id"
|
|
464
|
+
[class.is-resizing]="resizing?.id === widget.id"
|
|
465
|
+
[style.left.px]="getItemLeft(widget)"
|
|
466
|
+
[style.top.px]="getItemTop(widget)"
|
|
467
|
+
[style.width.px]="getItemWidth(widget)"
|
|
468
|
+
[style.height.px]="getItemHeight(widget)"
|
|
469
|
+
[style.z-index]="(dragging?.id === widget.id || resizing?.id === widget.id) ? 100 : 1"
|
|
470
|
+
(mousedown)="onDragStart($event, widget)"
|
|
471
|
+
>
|
|
472
|
+
<!-- Content stamped from parent template, context carries the widget -->
|
|
473
|
+
<ng-container
|
|
474
|
+
*ngIf="cellTemplate"
|
|
475
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
476
|
+
[ngTemplateOutletContext]="{ widget: widget }"
|
|
477
|
+
></ng-container>
|
|
478
|
+
|
|
479
|
+
<!-- Resize handle -->
|
|
480
|
+
<div class="resize-handle" (mousedown)="onResizeStart($event, widget)">
|
|
481
|
+
<svg width="12" height="12" viewBox="0 0 12 12">
|
|
482
|
+
<path d="M11 1v10H1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
483
|
+
<path d="M11 5v6H5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
484
|
+
<path d="M11 9v2H9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
485
|
+
</svg>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;width:100%;height:100%}.grid-container{position:relative;width:100%;min-height:100%;transition:height .25s ease}.grid-placeholder{position:absolute;background:var(--dash-accent-color, #0a84ff);opacity:.12;border-radius:24px;border:2px dashed var(--dash-accent-color, #0a84ff);transition:left .15s ease,top .15s ease,width .15s ease,height .15s ease;pointer-events:none;z-index:0}.grid-item{position:absolute;display:flex;flex-direction:column;transition:left .25s ease,top .25s ease,width .25s ease,height .25s ease;border-radius:24px;overflow:hidden}.grid-item.is-dragging,.grid-item.is-resizing{transition:none!important;opacity:.88;overflow:visible;filter:drop-shadow(0 20px 40px rgba(0,0,0,.45))}.resize-handle{position:absolute;right:4px;bottom:4px;width:22px;height:22px;cursor:nwse-resize;display:flex;align-items:center;justify-content:center;color:#ffffff40;border-radius:6px;transition:color .2s,background .2s;z-index:20}.resize-handle:hover{color:var(--dash-accent-color, #0a84ff);background:rgba(255,255,255,.06)}\n"] }]
|
|
490
|
+
}], ctorParameters: function () { return [{ type: i0.NgZone }, { type: i0.ChangeDetectorRef }]; }, propDecorators: { widgets: [{
|
|
491
|
+
type: Input
|
|
492
|
+
}], columns: [{
|
|
493
|
+
type: Input
|
|
494
|
+
}], gap: [{
|
|
495
|
+
type: Input
|
|
496
|
+
}], rowHeight: [{
|
|
497
|
+
type: Input
|
|
498
|
+
}], minItemCols: [{
|
|
499
|
+
type: Input
|
|
500
|
+
}], minItemRows: [{
|
|
501
|
+
type: Input
|
|
502
|
+
}], itemChanged: [{
|
|
503
|
+
type: Output
|
|
504
|
+
}], layoutChanged: [{
|
|
505
|
+
type: Output
|
|
506
|
+
}], gridContainer: [{
|
|
507
|
+
type: ViewChild,
|
|
508
|
+
args: ['gridContainer', { static: true }]
|
|
509
|
+
}], cellTemplate: [{
|
|
510
|
+
type: ContentChild,
|
|
511
|
+
args: [GridCellDirective, { read: TemplateRef }]
|
|
512
|
+
}] } });
|
|
13
513
|
|
|
14
514
|
class DashboardStateService {
|
|
15
|
-
constructor() {
|
|
16
|
-
this.
|
|
515
|
+
constructor(zone) {
|
|
516
|
+
this.zone = zone;
|
|
517
|
+
/**
|
|
518
|
+
* Shared key — stores page list + widget metadata (no positions).
|
|
519
|
+
* Read and written by every window.
|
|
520
|
+
*/
|
|
521
|
+
this.SHARED_KEY = 'ogidor_shared';
|
|
522
|
+
this.CHANNEL_NAME = 'ogidor_dashboard_sync';
|
|
523
|
+
this.channel = typeof BroadcastChannel !== 'undefined'
|
|
524
|
+
? new BroadcastChannel(this.CHANNEL_NAME) : null;
|
|
17
525
|
this.initialPages = [
|
|
18
|
-
{
|
|
19
|
-
id: 'page-1',
|
|
20
|
-
name: 'Default Workspace',
|
|
21
|
-
widgets: [
|
|
22
|
-
{
|
|
23
|
-
id: 'w-1',
|
|
24
|
-
type: 'LINE',
|
|
25
|
-
x: 0,
|
|
26
|
-
y: 0,
|
|
27
|
-
cols: 4,
|
|
28
|
-
rows: 4,
|
|
29
|
-
title: 'Market Trend',
|
|
30
|
-
data: { series: [{ name: 'Price', data: [30, 40, 35, 50, 49, 60, 70, 91, 125] }] }
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
id: 'w-2',
|
|
34
|
-
type: 'DONUT',
|
|
35
|
-
x: 4,
|
|
36
|
-
y: 0,
|
|
37
|
-
cols: 2,
|
|
38
|
-
rows: 4,
|
|
39
|
-
title: 'Asset Allocation',
|
|
40
|
-
data: { series: [44, 55, 41, 17, 15] }
|
|
41
|
-
}
|
|
42
|
-
]
|
|
43
|
-
}
|
|
526
|
+
{ id: 'page-1', name: 'Default Workspace', widgets: [] }
|
|
44
527
|
];
|
|
45
528
|
this.pagesSubject = new BehaviorSubject(this.initialPages);
|
|
46
529
|
this.activePageIdSubject = new BehaviorSubject(this.initialPages[0].id);
|
|
47
|
-
/**
|
|
48
|
-
* Emits whenever a widget's data is updated programmatically.
|
|
49
|
-
* Key = widget id, value = new data payload.
|
|
50
|
-
*/
|
|
51
|
-
this.widgetDataSubject = new Subject();
|
|
52
|
-
this.widgetData$ = this.widgetDataSubject.asObservable();
|
|
53
530
|
this.pages$ = this.pagesSubject.asObservable();
|
|
54
531
|
this.activePageId$ = this.activePageIdSubject.asObservable();
|
|
55
|
-
|
|
56
|
-
|
|
532
|
+
// Determine whether we are a pop-out window
|
|
533
|
+
const hash = typeof window !== 'undefined'
|
|
534
|
+
? window.location.hash.replace('#', '').trim() : '';
|
|
535
|
+
this.positionsKey = hash ? `ogidor_positions_${hash}` : 'ogidor_positions_main';
|
|
536
|
+
// 1. Load the shared structural state
|
|
537
|
+
const shared = localStorage.getItem(this.SHARED_KEY);
|
|
538
|
+
if (shared) {
|
|
57
539
|
try {
|
|
58
|
-
this.
|
|
540
|
+
this._applyShared(JSON.parse(shared), false);
|
|
59
541
|
}
|
|
60
542
|
catch (e) {
|
|
61
|
-
console.error('
|
|
543
|
+
console.error('[Dashboard] bad shared state', e);
|
|
62
544
|
}
|
|
63
545
|
}
|
|
546
|
+
// 2. Overlay this window's saved positions on top
|
|
547
|
+
this._restorePositions();
|
|
548
|
+
// 3. Listen for structural changes broadcast by other windows
|
|
549
|
+
if (this.channel) {
|
|
550
|
+
this.channel.onmessage = (ev) => {
|
|
551
|
+
this.zone.run(() => this._onSyncEvent(ev.data));
|
|
552
|
+
};
|
|
553
|
+
}
|
|
64
554
|
}
|
|
555
|
+
ngOnDestroy() { this.channel?.close(); }
|
|
556
|
+
// ── Page actions ──────────────────────────────────────────────
|
|
65
557
|
getActivePage() {
|
|
66
558
|
return this.pagesSubject.value.find(p => p.id === this.activePageIdSubject.value);
|
|
67
559
|
}
|
|
68
|
-
setActivePage(id) {
|
|
69
|
-
this.activePageIdSubject.next(id);
|
|
70
|
-
}
|
|
560
|
+
setActivePage(id) { this.activePageIdSubject.next(id); }
|
|
71
561
|
addPage(name) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
77
|
-
const updatedPages = [...this.pagesSubject.value, newPage];
|
|
78
|
-
this.pagesSubject.next(updatedPages);
|
|
79
|
-
this.activePageIdSubject.next(newPage.id);
|
|
80
|
-
this.saveToLocalStorage();
|
|
562
|
+
const page = { id: `page-${Date.now()}`, name, widgets: [] };
|
|
563
|
+
this.pagesSubject.next([...this.pagesSubject.value, page]);
|
|
564
|
+
this.activePageIdSubject.next(page.id);
|
|
565
|
+
this._saveShared();
|
|
566
|
+
this._broadcast({ type: 'PAGE_ADDED', page });
|
|
81
567
|
}
|
|
82
568
|
removePage(id) {
|
|
83
569
|
const pages = this.pagesSubject.value;
|
|
84
570
|
if (pages.length <= 1)
|
|
85
571
|
return;
|
|
86
|
-
const
|
|
87
|
-
this.pagesSubject.next(
|
|
572
|
+
const updated = pages.filter(p => p.id !== id);
|
|
573
|
+
this.pagesSubject.next(updated);
|
|
88
574
|
if (this.activePageIdSubject.value === id) {
|
|
89
|
-
this.activePageIdSubject.next(
|
|
575
|
+
this.activePageIdSubject.next(updated[0].id);
|
|
90
576
|
}
|
|
91
|
-
this.
|
|
577
|
+
this._saveShared();
|
|
578
|
+
this._broadcast({ type: 'PAGE_REMOVED', pageId: id });
|
|
92
579
|
}
|
|
93
|
-
|
|
580
|
+
// ── Widget structural actions (synced to all windows) ─────────
|
|
581
|
+
addWidget(widget) {
|
|
94
582
|
const activePage = this.getActivePage();
|
|
95
583
|
if (!activePage)
|
|
96
|
-
|
|
584
|
+
throw new Error('No active page');
|
|
97
585
|
const newWidget = {
|
|
98
586
|
id: `widget-${Date.now()}`,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
cols: type === 'DONUT' ? 2 : 4,
|
|
102
|
-
rows: 4,
|
|
103
|
-
title: `${type.charAt(0) + type.slice(1).toLowerCase()} Chart`,
|
|
104
|
-
data: this.getDefaultDataForType(type)
|
|
587
|
+
x: 0, y: 0, cols: 4, rows: 3,
|
|
588
|
+
...widget,
|
|
105
589
|
};
|
|
106
|
-
activePage.widgets.
|
|
107
|
-
this.
|
|
590
|
+
activePage.widgets = [...activePage.widgets, newWidget];
|
|
591
|
+
this.pagesSubject.next([...this.pagesSubject.value]);
|
|
592
|
+
this._saveShared();
|
|
593
|
+
this._savePositions(); // register default position locally
|
|
594
|
+
this._broadcast({ type: 'WIDGET_ADDED', pageId: activePage.id, widget: newWidget });
|
|
595
|
+
return newWidget;
|
|
108
596
|
}
|
|
109
|
-
|
|
597
|
+
updateWidget(widgetId, patch) {
|
|
598
|
+
const pages = this.pagesSubject.value;
|
|
599
|
+
for (const page of pages) {
|
|
600
|
+
const w = page.widgets.find(w => w.id === widgetId);
|
|
601
|
+
if (w) {
|
|
602
|
+
Object.assign(w, patch);
|
|
603
|
+
this.pagesSubject.next([...pages]);
|
|
604
|
+
this._saveShared();
|
|
605
|
+
this._broadcast({ type: 'WIDGET_META', widgetId, patch });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
removeWidget(widgetId) {
|
|
110
611
|
const activePage = this.getActivePage();
|
|
111
612
|
if (!activePage)
|
|
112
613
|
return;
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
title,
|
|
120
|
-
data
|
|
121
|
-
};
|
|
122
|
-
activePage.widgets.push(newWidget);
|
|
123
|
-
this.updatePages(this.pagesSubject.value);
|
|
124
|
-
}
|
|
125
|
-
getDefaultDataForType(type) {
|
|
126
|
-
if (type === 'DONUT') {
|
|
127
|
-
return { series: [30, 20, 50] };
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
series: [{
|
|
131
|
-
name: 'Sample Data',
|
|
132
|
-
data: Array.from({ length: 10 }, () => Math.floor(Math.random() * 100))
|
|
133
|
-
}]
|
|
134
|
-
};
|
|
614
|
+
const pageId = activePage.id;
|
|
615
|
+
activePage.widgets = activePage.widgets.filter(w => w.id !== widgetId);
|
|
616
|
+
this.pagesSubject.next([...this.pagesSubject.value]);
|
|
617
|
+
this._saveShared();
|
|
618
|
+
this._savePositions();
|
|
619
|
+
this._broadcast({ type: 'WIDGET_REMOVED', pageId, widgetId });
|
|
135
620
|
}
|
|
621
|
+
// ── Position actions (local to THIS window only) ──────────────
|
|
622
|
+
/**
|
|
623
|
+
* Update a widget's grid position/size.
|
|
624
|
+
* Saved only for this window — never broadcast to others.
|
|
625
|
+
*/
|
|
136
626
|
updateWidgetPosition(pageId, widgetId, x, y, cols, rows) {
|
|
137
627
|
const pages = this.pagesSubject.value;
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
this.updatePages(pages);
|
|
147
|
-
}
|
|
628
|
+
const widget = pages.find(p => p.id === pageId)?.widgets.find(w => w.id === widgetId);
|
|
629
|
+
if (widget) {
|
|
630
|
+
widget.x = x;
|
|
631
|
+
widget.y = y;
|
|
632
|
+
widget.cols = cols;
|
|
633
|
+
widget.rows = rows;
|
|
634
|
+
this.pagesSubject.next([...pages]);
|
|
635
|
+
this._savePositions(); // local only — intentionally no _saveShared / no broadcast
|
|
148
636
|
}
|
|
149
637
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
}
|
|
638
|
+
// ── Serialization ─────────────────────────────────────────────
|
|
639
|
+
serializeLayout() {
|
|
640
|
+
return JSON.stringify({
|
|
641
|
+
pages: this.pagesSubject.value,
|
|
642
|
+
activePageId: this.activePageIdSubject.value,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
loadLayout(config) {
|
|
646
|
+
if (!config?.pages)
|
|
647
|
+
return;
|
|
648
|
+
this._applyShared(config, true);
|
|
649
|
+
this._saveShared();
|
|
650
|
+
this._savePositions();
|
|
156
651
|
}
|
|
652
|
+
popOutPage(pageId) {
|
|
653
|
+
const url = `${window.location.origin}${window.location.pathname}#${pageId}`;
|
|
654
|
+
window.open(url, `workspace_${pageId}`, 'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
|
655
|
+
}
|
|
656
|
+
// ── Private helpers ───────────────────────────────────────────
|
|
157
657
|
/**
|
|
158
|
-
*
|
|
159
|
-
* The widget's chart will re-render immediately.
|
|
160
|
-
*
|
|
161
|
-
* @param widgetId The `id` of the target widget.
|
|
162
|
-
* @param data New data — `LineBarData` for LINE/BAR, `DonutData` for DONUT.
|
|
658
|
+
* Save page list + widget metadata (titles, cardColor, data) — no positions.
|
|
163
659
|
*/
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
660
|
+
_saveShared() {
|
|
661
|
+
const stripped = this.pagesSubject.value.map(p => ({
|
|
662
|
+
...p,
|
|
663
|
+
widgets: p.widgets.map(({ id, title, cardColor, data, cols, rows }) =>
|
|
664
|
+
// Keep default cols/rows so new windows get a sensible first-open layout.
|
|
665
|
+
// x/y are intentionally omitted from shared state.
|
|
666
|
+
({ id, title, cardColor, data, x: 0, y: 0, cols, rows })),
|
|
667
|
+
}));
|
|
668
|
+
localStorage.setItem(this.SHARED_KEY, JSON.stringify({
|
|
669
|
+
pages: stripped,
|
|
670
|
+
activePageId: this.activePageIdSubject.value,
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Save this window's grid positions (x, y, cols, rows) per widget.
|
|
675
|
+
*/
|
|
676
|
+
_savePositions() {
|
|
677
|
+
const positions = {};
|
|
678
|
+
for (const page of this.pagesSubject.value) {
|
|
679
|
+
for (const w of page.widgets) {
|
|
680
|
+
positions[`${page.id}:${w.id}`] = { x: w.x, y: w.y, cols: w.cols, rows: w.rows };
|
|
173
681
|
}
|
|
174
682
|
}
|
|
175
|
-
|
|
683
|
+
localStorage.setItem(this.positionsKey, JSON.stringify(positions));
|
|
176
684
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
685
|
+
/**
|
|
686
|
+
* Overlay the positions saved for THIS window on top of the current pages.
|
|
687
|
+
*/
|
|
688
|
+
_restorePositions() {
|
|
689
|
+
const raw = localStorage.getItem(this.positionsKey);
|
|
690
|
+
if (!raw)
|
|
691
|
+
return;
|
|
692
|
+
try {
|
|
693
|
+
const positions = JSON.parse(raw);
|
|
694
|
+
const pages = this.pagesSubject.value;
|
|
695
|
+
for (const page of pages) {
|
|
696
|
+
for (const w of page.widgets) {
|
|
697
|
+
const pos = positions[`${page.id}:${w.id}`];
|
|
698
|
+
if (pos) {
|
|
699
|
+
w.x = pos.x;
|
|
700
|
+
w.y = pos.y;
|
|
701
|
+
w.cols = pos.cols;
|
|
702
|
+
w.rows = pos.rows;
|
|
703
|
+
}
|
|
191
704
|
}
|
|
192
|
-
this.updatePages(pages);
|
|
193
|
-
return;
|
|
194
705
|
}
|
|
706
|
+
this.pagesSubject.next([...pages]);
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
console.error('[Dashboard] bad positions state', e);
|
|
195
710
|
}
|
|
196
711
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
712
|
+
/**
|
|
713
|
+
* Apply a shared config object (page list + metadata) without touching
|
|
714
|
+
* this window's saved positions.
|
|
715
|
+
*/
|
|
716
|
+
_applyShared(config, overwritePositions) {
|
|
717
|
+
if (!config?.pages)
|
|
718
|
+
return;
|
|
719
|
+
this.pagesSubject.next(config.pages);
|
|
720
|
+
if (config.activePageId)
|
|
721
|
+
this.activePageIdSubject.next(config.activePageId);
|
|
722
|
+
if (!overwritePositions)
|
|
723
|
+
this._restorePositions();
|
|
207
724
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Handle a structural sync event arriving from another window.
|
|
727
|
+
* Position changes are never sent so we never receive them here.
|
|
728
|
+
*/
|
|
729
|
+
_onSyncEvent(event) {
|
|
730
|
+
// Work on a shallow clone of pages so Angular detects the change
|
|
731
|
+
const pages = this.pagesSubject.value.map(p => ({
|
|
732
|
+
...p, widgets: [...p.widgets]
|
|
733
|
+
}));
|
|
734
|
+
switch (event.type) {
|
|
735
|
+
case 'PAGE_ADDED':
|
|
736
|
+
if (!pages.find(p => p.id === event.page.id)) {
|
|
737
|
+
pages.push({ ...event.page, widgets: [...event.page.widgets] });
|
|
738
|
+
this.pagesSubject.next(pages);
|
|
739
|
+
this._saveShared();
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
case 'PAGE_REMOVED': {
|
|
743
|
+
const updated = pages.filter(p => p.id !== event.pageId);
|
|
744
|
+
if (updated.length !== pages.length) {
|
|
745
|
+
this.pagesSubject.next(updated);
|
|
746
|
+
if (this.activePageIdSubject.value === event.pageId) {
|
|
747
|
+
this.activePageIdSubject.next(updated[0]?.id ?? '');
|
|
748
|
+
}
|
|
749
|
+
this._saveShared();
|
|
750
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case 'WIDGET_ADDED': {
|
|
754
|
+
const page = pages.find(p => p.id === event.pageId);
|
|
755
|
+
if (page && !page.widgets.find(w => w.id === event.widget.id)) {
|
|
756
|
+
page.widgets = [...page.widgets, { ...event.widget }];
|
|
757
|
+
this.pagesSubject.next(pages);
|
|
758
|
+
this._saveShared();
|
|
759
|
+
// Don't touch positions — this window will keep its own layout for
|
|
760
|
+
// existing widgets; the new one starts at its default position.
|
|
761
|
+
}
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
case 'WIDGET_REMOVED': {
|
|
765
|
+
const page = pages.find(p => p.id === event.pageId);
|
|
766
|
+
if (page) {
|
|
767
|
+
page.widgets = page.widgets.filter(w => w.id !== event.widgetId);
|
|
768
|
+
this.pagesSubject.next(pages);
|
|
769
|
+
this._saveShared();
|
|
770
|
+
this._savePositions();
|
|
771
|
+
}
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
case 'WIDGET_META': {
|
|
775
|
+
for (const page of pages) {
|
|
776
|
+
const w = page.widgets.find(w => w.id === event.widgetId);
|
|
777
|
+
if (w) {
|
|
778
|
+
Object.assign(w, event.patch);
|
|
779
|
+
this.pagesSubject.next(pages);
|
|
780
|
+
this._saveShared();
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
213
785
|
}
|
|
214
786
|
}
|
|
215
787
|
}
|
|
216
|
-
|
|
217
|
-
|
|
788
|
+
_broadcast(event) {
|
|
789
|
+
this.channel?.postMessage(event);
|
|
218
790
|
}
|
|
219
791
|
}
|
|
220
|
-
DashboardStateService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
792
|
+
DashboardStateService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardStateService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
221
793
|
DashboardStateService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardStateService, providedIn: 'root' });
|
|
222
794
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardStateService, decorators: [{
|
|
223
795
|
type: Injectable,
|
|
224
|
-
args: [{
|
|
225
|
-
|
|
226
|
-
}]
|
|
227
|
-
}], ctorParameters: function () { return []; } });
|
|
796
|
+
args: [{ providedIn: 'root' }]
|
|
797
|
+
}], ctorParameters: function () { return [{ type: i0.NgZone }]; } });
|
|
228
798
|
|
|
229
|
-
const DEFAULT_CHART_COLORS = ['#0a84ff', '#30d158', '#ff9f0a', '#bf5af2', '#ff453a'];
|
|
230
|
-
const DEFAULT_FORECOLOR = '#8e8e93';
|
|
231
|
-
const DEFAULT_CARD_BG = '#2c2c2e';
|
|
232
799
|
class WidgetRendererComponent {
|
|
233
|
-
constructor(
|
|
234
|
-
this.stateService = stateService;
|
|
800
|
+
constructor() {
|
|
235
801
|
this.editRequested = new EventEmitter();
|
|
236
|
-
this.
|
|
237
|
-
this.
|
|
238
|
-
}
|
|
239
|
-
ngOnInit() {
|
|
240
|
-
this.applyStyles();
|
|
241
|
-
this.initChart();
|
|
242
|
-
this.subs.add(this.stateService.widgetData$.subscribe(({ widgetId, data }) => {
|
|
243
|
-
if (widgetId === this.widget.id) {
|
|
244
|
-
this.widget = { ...this.widget, data };
|
|
245
|
-
this.initChart();
|
|
246
|
-
}
|
|
247
|
-
}));
|
|
802
|
+
this.removeRequested = new EventEmitter();
|
|
803
|
+
this.cardBg = 'var(--dash-card-bg, #2c2c2e)';
|
|
248
804
|
}
|
|
249
805
|
ngOnChanges(changes) {
|
|
250
|
-
if (changes['theme']) {
|
|
251
|
-
this.
|
|
252
|
-
this.initChart();
|
|
806
|
+
if (changes['widget'] || changes['theme']) {
|
|
807
|
+
this.cardBg = this.widget?.cardColor ?? 'var(--dash-card-bg, #2c2c2e)';
|
|
253
808
|
}
|
|
254
|
-
if (changes['widget'] && !changes['widget'].firstChange) {
|
|
255
|
-
this.applyStyles();
|
|
256
|
-
this.initChart();
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
ngOnDestroy() { this.subs.unsubscribe(); }
|
|
260
|
-
applyStyles() {
|
|
261
|
-
this.cardStyles = {
|
|
262
|
-
background: this.widget.cardColor ?? 'var(--dash-card-bg)'
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
resolvedColors() {
|
|
266
|
-
return this.widget.colors ?? this.theme?.chartColors ?? DEFAULT_CHART_COLORS;
|
|
267
809
|
}
|
|
268
|
-
initChart() {
|
|
269
|
-
const isDonut = this.widget.type === 'DONUT';
|
|
270
|
-
const colors = this.resolvedColors();
|
|
271
|
-
const foreColor = this.theme?.chartForeColor ?? DEFAULT_FORECOLOR;
|
|
272
|
-
const lineBarData = !isDonut ? this.widget.data : null;
|
|
273
|
-
const donutData = isDonut ? this.widget.data : null;
|
|
274
|
-
const categories = lineBarData?.categories ?? [];
|
|
275
|
-
const donutLabels = donutData?.labels ?? ['A', 'B', 'C', 'D', 'E'];
|
|
276
|
-
this.chartOptions = {
|
|
277
|
-
series: this.widget.data.series,
|
|
278
|
-
chart: {
|
|
279
|
-
width: '100%', height: '100%',
|
|
280
|
-
type: this.widget.type.toLowerCase(),
|
|
281
|
-
toolbar: { show: false },
|
|
282
|
-
animations: { enabled: true, speed: 400 },
|
|
283
|
-
background: 'transparent', foreColor,
|
|
284
|
-
parentHeightOffset: 0, sparkline: { enabled: false },
|
|
285
|
-
},
|
|
286
|
-
theme: { mode: 'dark' },
|
|
287
|
-
colors,
|
|
288
|
-
stroke: { curve: 'smooth', width: isDonut ? 0 : 3 },
|
|
289
|
-
dataLabels: { enabled: false },
|
|
290
|
-
legend: { position: 'bottom', labels: { colors: foreColor } },
|
|
291
|
-
tooltip: { theme: 'dark' },
|
|
292
|
-
plotOptions: {
|
|
293
|
-
pie: { donut: { size: '70%', labels: { show: true, total: { show: true, label: 'Total', color: '#fff' } } } },
|
|
294
|
-
bar: { borderRadius: 8, columnWidth: '50%' }
|
|
295
|
-
},
|
|
296
|
-
xaxis: {
|
|
297
|
-
categories: categories.length ? categories : undefined,
|
|
298
|
-
labels: { style: { colors: foreColor } },
|
|
299
|
-
axisBorder: { show: false }, axisTicks: { show: false }
|
|
300
|
-
},
|
|
301
|
-
yaxis: { labels: { style: { colors: foreColor } } },
|
|
302
|
-
labels: isDonut ? donutLabels : undefined,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
onRemove() { this.onRemoveWidget(this.widget.id); }
|
|
306
810
|
}
|
|
307
|
-
WidgetRendererComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: WidgetRendererComponent, deps: [
|
|
308
|
-
WidgetRendererComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.10", type: WidgetRendererComponent, selector: "app-widget-renderer", inputs: { widget: "widget",
|
|
309
|
-
<div class="widget-
|
|
811
|
+
WidgetRendererComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: WidgetRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
812
|
+
WidgetRendererComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.10", type: WidgetRendererComponent, selector: "app-widget-renderer", inputs: { widget: "widget", theme: "theme" }, outputs: { editRequested: "editRequested", removeRequested: "removeRequested" }, usesOnChanges: true, ngImport: i0, template: `
|
|
813
|
+
<div class="widget-card" [style.background]="cardBg">
|
|
814
|
+
<!-- Header: drag handle + title + actions -->
|
|
310
815
|
<div class="widget-header">
|
|
816
|
+
<div class="drag-handle" title="Drag to move">
|
|
817
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
818
|
+
<circle cx="4" cy="3" r="1.2"/><circle cx="10" cy="3" r="1.2"/>
|
|
819
|
+
<circle cx="4" cy="7" r="1.2"/><circle cx="10" cy="7" r="1.2"/>
|
|
820
|
+
<circle cx="4" cy="11" r="1.2"/><circle cx="10" cy="11" r="1.2"/>
|
|
821
|
+
</svg>
|
|
822
|
+
</div>
|
|
311
823
|
<span class="widget-title">{{ widget.title }}</span>
|
|
312
824
|
<div class="header-actions">
|
|
313
|
-
<button class="btn-icon btn-edit"
|
|
825
|
+
<button class="btn-icon btn-edit" (click)="editRequested.emit(widget)" title="Edit">
|
|
314
826
|
<i class="la la-pen"></i>
|
|
315
827
|
</button>
|
|
316
|
-
<button class="btn-icon btn-
|
|
828
|
+
<button class="btn-icon btn-remove" (click)="removeRequested.emit(widget.id)" title="Remove">
|
|
317
829
|
<i class="la la-times"></i>
|
|
318
830
|
</button>
|
|
319
831
|
</div>
|
|
320
832
|
</div>
|
|
833
|
+
|
|
834
|
+
<!-- Consumer content slot -->
|
|
321
835
|
<div class="widget-body">
|
|
322
|
-
<
|
|
323
|
-
*ngIf="chartOptions.chart"
|
|
324
|
-
style="display:block;width:100%;height:100%;"
|
|
325
|
-
[series]="chartOptions.series!"
|
|
326
|
-
[chart]="chartOptions.chart!"
|
|
327
|
-
[xaxis]="chartOptions.xaxis!"
|
|
328
|
-
[stroke]="chartOptions.stroke!"
|
|
329
|
-
[dataLabels]="chartOptions.dataLabels!"
|
|
330
|
-
[plotOptions]="chartOptions.plotOptions!"
|
|
331
|
-
[yaxis]="chartOptions.yaxis!"
|
|
332
|
-
[labels]="chartOptions.labels!"
|
|
333
|
-
[legend]="chartOptions.legend!"
|
|
334
|
-
[colors]="chartOptions.colors!"
|
|
335
|
-
[theme]="chartOptions.theme!"
|
|
336
|
-
[tooltip]="chartOptions.tooltip!"
|
|
337
|
-
></apx-chart>
|
|
836
|
+
<ng-content></ng-content>
|
|
338
837
|
</div>
|
|
339
838
|
</div>
|
|
340
|
-
`, isInline: true, styles: [".widget-
|
|
839
|
+
`, isInline: true, styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;width:100%}.widget-card{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;border-radius:24px;overflow:hidden;box-shadow:0 8px 24px #00000040;border:1px solid rgba(255,255,255,.07);transition:box-shadow .2s ease;box-sizing:border-box;background:var(--dash-card-bg, #2c2c2e)}.widget-card:hover{box-shadow:0 12px 32px #00000059}.widget-header{display:flex;align-items:center;gap:8px;padding:12px 14px 8px;flex-shrink:0}.drag-handle{color:#fff3;cursor:grab;display:flex;align-items:center;flex-shrink:0;transition:color .2s}.drag-handle:hover{color:#ffffff80}.widget-title{flex:1;color:#fff;font-size:14px;font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.header-actions{display:flex;gap:4px;align-items:center;opacity:0;transition:opacity .2s}.widget-card:hover .header-actions{opacity:1}.btn-icon{background:rgba(255,255,255,.07);border:none;color:#ffffff80;cursor:pointer;font-size:11px;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}.btn-edit:hover{background:rgba(10,132,255,.22);color:var(--dash-accent-color, #0a84ff)}.btn-remove:hover{background:rgba(255,69,58,.22);color:var(--dash-danger-color, #ff453a)}.widget-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:0 14px 14px;overflow:hidden}\n"] });
|
|
341
840
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: WidgetRendererComponent, decorators: [{
|
|
342
841
|
type: Component,
|
|
343
842
|
args: [{ selector: 'app-widget-renderer', template: `
|
|
344
|
-
<div class="widget-
|
|
843
|
+
<div class="widget-card" [style.background]="cardBg">
|
|
844
|
+
<!-- Header: drag handle + title + actions -->
|
|
345
845
|
<div class="widget-header">
|
|
846
|
+
<div class="drag-handle" title="Drag to move">
|
|
847
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
848
|
+
<circle cx="4" cy="3" r="1.2"/><circle cx="10" cy="3" r="1.2"/>
|
|
849
|
+
<circle cx="4" cy="7" r="1.2"/><circle cx="10" cy="7" r="1.2"/>
|
|
850
|
+
<circle cx="4" cy="11" r="1.2"/><circle cx="10" cy="11" r="1.2"/>
|
|
851
|
+
</svg>
|
|
852
|
+
</div>
|
|
346
853
|
<span class="widget-title">{{ widget.title }}</span>
|
|
347
854
|
<div class="header-actions">
|
|
348
|
-
<button class="btn-icon btn-edit"
|
|
855
|
+
<button class="btn-icon btn-edit" (click)="editRequested.emit(widget)" title="Edit">
|
|
349
856
|
<i class="la la-pen"></i>
|
|
350
857
|
</button>
|
|
351
|
-
<button class="btn-icon btn-
|
|
858
|
+
<button class="btn-icon btn-remove" (click)="removeRequested.emit(widget.id)" title="Remove">
|
|
352
859
|
<i class="la la-times"></i>
|
|
353
860
|
</button>
|
|
354
861
|
</div>
|
|
355
862
|
</div>
|
|
863
|
+
|
|
864
|
+
<!-- Consumer content slot -->
|
|
356
865
|
<div class="widget-body">
|
|
357
|
-
<
|
|
358
|
-
*ngIf="chartOptions.chart"
|
|
359
|
-
style="display:block;width:100%;height:100%;"
|
|
360
|
-
[series]="chartOptions.series!"
|
|
361
|
-
[chart]="chartOptions.chart!"
|
|
362
|
-
[xaxis]="chartOptions.xaxis!"
|
|
363
|
-
[stroke]="chartOptions.stroke!"
|
|
364
|
-
[dataLabels]="chartOptions.dataLabels!"
|
|
365
|
-
[plotOptions]="chartOptions.plotOptions!"
|
|
366
|
-
[yaxis]="chartOptions.yaxis!"
|
|
367
|
-
[labels]="chartOptions.labels!"
|
|
368
|
-
[legend]="chartOptions.legend!"
|
|
369
|
-
[colors]="chartOptions.colors!"
|
|
370
|
-
[theme]="chartOptions.theme!"
|
|
371
|
-
[tooltip]="chartOptions.tooltip!"
|
|
372
|
-
></apx-chart>
|
|
866
|
+
<ng-content></ng-content>
|
|
373
867
|
</div>
|
|
374
868
|
</div>
|
|
375
|
-
`, styles: [".widget-
|
|
376
|
-
}],
|
|
377
|
-
type: Input
|
|
378
|
-
}], onRemoveWidget: [{
|
|
869
|
+
`, styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0;width:100%}.widget-card{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;border-radius:24px;overflow:hidden;box-shadow:0 8px 24px #00000040;border:1px solid rgba(255,255,255,.07);transition:box-shadow .2s ease;box-sizing:border-box;background:var(--dash-card-bg, #2c2c2e)}.widget-card:hover{box-shadow:0 12px 32px #00000059}.widget-header{display:flex;align-items:center;gap:8px;padding:12px 14px 8px;flex-shrink:0}.drag-handle{color:#fff3;cursor:grab;display:flex;align-items:center;flex-shrink:0;transition:color .2s}.drag-handle:hover{color:#ffffff80}.widget-title{flex:1;color:#fff;font-size:14px;font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.header-actions{display:flex;gap:4px;align-items:center;opacity:0;transition:opacity .2s}.widget-card:hover .header-actions{opacity:1}.btn-icon{background:rgba(255,255,255,.07);border:none;color:#ffffff80;cursor:pointer;font-size:11px;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}.btn-edit:hover{background:rgba(10,132,255,.22);color:var(--dash-accent-color, #0a84ff)}.btn-remove:hover{background:rgba(255,69,58,.22);color:var(--dash-danger-color, #ff453a)}.widget-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:0 14px 14px;overflow:hidden}\n"] }]
|
|
870
|
+
}], propDecorators: { widget: [{
|
|
379
871
|
type: Input
|
|
380
872
|
}], theme: [{
|
|
381
873
|
type: Input
|
|
382
874
|
}], editRequested: [{
|
|
383
875
|
type: Output
|
|
876
|
+
}], removeRequested: [{
|
|
877
|
+
type: Output
|
|
384
878
|
}] } });
|
|
385
879
|
|
|
386
880
|
const DEFAULT_THEME = {
|
|
387
881
|
backgroundColor: '#000000',
|
|
388
882
|
panelColor: '#1c1c1e',
|
|
389
883
|
widgetCardColor: '#2c2c2e',
|
|
390
|
-
|
|
884
|
+
foreColor: '#8e8e93',
|
|
391
885
|
accentColor: '#0a84ff',
|
|
392
886
|
dangerColor: '#ff453a',
|
|
393
|
-
chartColors: ['#0a84ff', '#30d158', '#ff9f0a', '#bf5af2', '#ff453a'],
|
|
394
887
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
395
888
|
};
|
|
396
889
|
class DashboardComponent {
|
|
397
890
|
constructor(stateService) {
|
|
398
891
|
this.stateService = stateService;
|
|
892
|
+
/**
|
|
893
|
+
* Emits when the user clicks "Add Widget".
|
|
894
|
+
* The consumer should open their own dialog and call `stateService.addWidget(...)`.
|
|
895
|
+
*/
|
|
896
|
+
this.addWidgetRequested = new EventEmitter();
|
|
897
|
+
/**
|
|
898
|
+
* Emits the Widget when the user clicks the edit (pen) icon on a card.
|
|
899
|
+
* The consumer should open their own edit UI and call `stateService.updateWidget(...)`.
|
|
900
|
+
*/
|
|
901
|
+
this.editWidgetRequested = new EventEmitter();
|
|
399
902
|
this.resolvedTheme = { ...DEFAULT_THEME };
|
|
400
903
|
this.wrapperStyles = {};
|
|
401
904
|
this.pages = [];
|
|
402
905
|
this.activePageId = '';
|
|
403
|
-
|
|
404
|
-
this.dialogOpen = false;
|
|
405
|
-
this.dialogType = 'LINE';
|
|
406
|
-
this.dialogTitle = '';
|
|
407
|
-
this.dialogCategories = '';
|
|
408
|
-
this.dialogSeries = [{ name: 'Series 1', dataRaw: '' }];
|
|
409
|
-
this.dialogSlices = [
|
|
410
|
-
{ label: 'Slice A', value: null },
|
|
411
|
-
{ label: 'Slice B', value: null },
|
|
412
|
-
{ label: 'Slice C', value: null },
|
|
413
|
-
];
|
|
414
|
-
this.widgetTypes = [
|
|
415
|
-
{ value: 'LINE', label: 'Line', icon: 'la-chart-area' },
|
|
416
|
-
{ value: 'BAR', label: 'Bar', icon: 'la-chart-bar' },
|
|
417
|
-
{ value: 'DONUT', label: 'Donut', icon: 'la-chart-pie' },
|
|
418
|
-
];
|
|
906
|
+
this.isPoppedOut = false;
|
|
419
907
|
this.subs = new Subscription();
|
|
420
|
-
// ── Edit dialog state ──
|
|
421
|
-
this.editDialogOpen = false;
|
|
422
|
-
this.editWidgetType = 'LINE';
|
|
423
|
-
this.editTitle = '';
|
|
424
|
-
this.editCardColor = '';
|
|
425
|
-
this.editColors = [];
|
|
426
|
-
this.editSeries = [];
|
|
427
|
-
this.editCategories = '';
|
|
428
|
-
this.editSlices = [];
|
|
429
|
-
this.DEFAULT_COLORS = ['#0a84ff', '#30d158', '#ff9f0a', '#bf5af2', '#ff453a'];
|
|
430
|
-
this.removeWidgetHandler = (id) => { this.stateService.removeWidget(id); };
|
|
431
908
|
}
|
|
432
909
|
ngOnChanges(changes) {
|
|
433
910
|
if (changes['theme'] || changes['initialLayout'])
|
|
@@ -443,21 +920,16 @@ class DashboardComponent {
|
|
|
443
920
|
console.error('[Dashboard] Failed to parse initialLayout', e);
|
|
444
921
|
}
|
|
445
922
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
pushItems: true,
|
|
450
|
-
draggable: { enabled: true },
|
|
451
|
-
resizable: { enabled: true },
|
|
452
|
-
minCols: 12, maxCols: 12,
|
|
453
|
-
minRows: 12, maxRows: 50,
|
|
454
|
-
margin: 20, outerMargin: true,
|
|
455
|
-
itemChangeCallback: (item) => {
|
|
456
|
-
this.stateService.updateWidgetPosition(this.activePageId, item.id, item.x, item.y, item.cols, item.rows);
|
|
457
|
-
}
|
|
458
|
-
};
|
|
923
|
+
const hash = window.location.hash?.replace('#', '').trim();
|
|
924
|
+
if (hash)
|
|
925
|
+
this.isPoppedOut = true;
|
|
459
926
|
this.subs.add(this.stateService.pages$.subscribe(pages => {
|
|
460
927
|
this.pages = pages;
|
|
928
|
+
if (this.isPoppedOut && hash) {
|
|
929
|
+
const target = pages.find(p => p.id === hash);
|
|
930
|
+
if (target)
|
|
931
|
+
this.stateService.setActivePage(hash);
|
|
932
|
+
}
|
|
461
933
|
this.updateActivePage();
|
|
462
934
|
}));
|
|
463
935
|
this.subs.add(this.stateService.activePageId$.subscribe(id => {
|
|
@@ -466,25 +938,13 @@ class DashboardComponent {
|
|
|
466
938
|
}));
|
|
467
939
|
}
|
|
468
940
|
ngOnDestroy() { this.subs.unsubscribe(); }
|
|
469
|
-
|
|
470
|
-
this.
|
|
471
|
-
this.wrapperStyles = {
|
|
472
|
-
'--dash-bg': this.resolvedTheme.backgroundColor,
|
|
473
|
-
'--dash-panel-bg': this.resolvedTheme.panelColor,
|
|
474
|
-
'--dash-card-bg': this.resolvedTheme.widgetCardColor,
|
|
475
|
-
'--dash-fore-color': this.resolvedTheme.chartForeColor,
|
|
476
|
-
'--dash-accent-color': this.resolvedTheme.accentColor,
|
|
477
|
-
'--dash-danger-color': this.resolvedTheme.dangerColor,
|
|
478
|
-
'--dash-font-family': this.resolvedTheme.fontFamily,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
updateActivePage() {
|
|
482
|
-
this.activePage = this.pages.find(p => p.id === this.activePageId);
|
|
941
|
+
onItemChanged(widget) {
|
|
942
|
+
this.stateService.updateWidgetPosition(this.activePageId, widget.id, widget.x, widget.y, widget.cols, widget.rows);
|
|
483
943
|
}
|
|
484
|
-
|
|
944
|
+
onRemoveWidget(widgetId) { this.stateService.removeWidget(widgetId); }
|
|
485
945
|
onSelectPage(id) { this.stateService.setActivePage(id); }
|
|
486
946
|
onAddPage() {
|
|
487
|
-
const name = prompt('
|
|
947
|
+
const name = prompt('Workspace name:', `Workspace ${this.pages.length + 1}`);
|
|
488
948
|
if (name)
|
|
489
949
|
this.stateService.addPage(name);
|
|
490
950
|
}
|
|
@@ -493,653 +953,277 @@ class DashboardComponent {
|
|
|
493
953
|
if (confirm('Remove this workspace?'))
|
|
494
954
|
this.stateService.removePage(id);
|
|
495
955
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
this.
|
|
499
|
-
this.dialogTitle = '';
|
|
500
|
-
this.dialogCategories = '';
|
|
501
|
-
this.dialogSeries = [{ name: 'Series 1', dataRaw: '' }];
|
|
502
|
-
this.dialogSlices = [
|
|
503
|
-
{ label: 'Slice A', value: null },
|
|
504
|
-
{ label: 'Slice B', value: null },
|
|
505
|
-
{ label: 'Slice C', value: null },
|
|
506
|
-
];
|
|
507
|
-
this.dialogOpen = true;
|
|
508
|
-
}
|
|
509
|
-
closeDialog() { this.dialogOpen = false; }
|
|
510
|
-
addSeries() { this.dialogSeries.push({ name: `Series ${this.dialogSeries.length + 1}`, dataRaw: '' }); }
|
|
511
|
-
removeSeries(i) { this.dialogSeries.splice(i, 1); }
|
|
512
|
-
addSlice() { this.dialogSlices.push({ label: `Slice ${this.dialogSlices.length + 1}`, value: null }); }
|
|
513
|
-
removeSlice(i) { this.dialogSlices.splice(i, 1); }
|
|
514
|
-
confirmAddWidget() {
|
|
515
|
-
const title = this.dialogTitle.trim();
|
|
516
|
-
if (!title)
|
|
517
|
-
return;
|
|
518
|
-
let data;
|
|
519
|
-
if (this.dialogType === 'DONUT') {
|
|
520
|
-
const validSlices = this.dialogSlices.filter(s => s.value !== null && s.label.trim());
|
|
521
|
-
data = {
|
|
522
|
-
series: validSlices.length
|
|
523
|
-
? validSlices.map(s => Number(s.value))
|
|
524
|
-
: [30, 20, 50],
|
|
525
|
-
labels: validSlices.length
|
|
526
|
-
? validSlices.map(s => s.label)
|
|
527
|
-
: ['A', 'B', 'C'],
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
const parseNums = (raw) => raw.split(',').map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
|
532
|
-
data = {
|
|
533
|
-
series: this.dialogSeries.map(s => ({
|
|
534
|
-
name: s.name || 'Series',
|
|
535
|
-
data: s.dataRaw.trim()
|
|
536
|
-
? parseNums(s.dataRaw)
|
|
537
|
-
: Array.from({ length: 8 }, () => Math.floor(Math.random() * 100)),
|
|
538
|
-
})),
|
|
539
|
-
categories: this.dialogCategories.trim()
|
|
540
|
-
? this.dialogCategories.split(',').map(c => c.trim())
|
|
541
|
-
: undefined,
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
this.stateService.addWidgetWithData(this.dialogType, title, data);
|
|
545
|
-
this.closeDialog();
|
|
546
|
-
}
|
|
547
|
-
openEditDialog(widget) {
|
|
548
|
-
this.editingWidget = widget;
|
|
549
|
-
this.editWidgetType = widget.type;
|
|
550
|
-
this.editTitle = widget.title;
|
|
551
|
-
this.editCardColor = widget.cardColor ?? this.resolvedTheme.widgetCardColor;
|
|
552
|
-
this.editColors = widget.colors?.length
|
|
553
|
-
? [...widget.colors]
|
|
554
|
-
: [...this.resolvedTheme.chartColors];
|
|
555
|
-
if (widget.type === 'DONUT') {
|
|
556
|
-
const d = widget.data;
|
|
557
|
-
this.editSlices = d.series.map((v, i) => ({
|
|
558
|
-
label: d.labels?.[i] ?? `Slice ${i + 1}`,
|
|
559
|
-
value: v,
|
|
560
|
-
}));
|
|
561
|
-
this.editColors = this.editColors.slice(0, this.editSlices.length);
|
|
562
|
-
while (this.editColors.length < this.editSlices.length) {
|
|
563
|
-
this.editColors.push(this.DEFAULT_COLORS[this.editColors.length % this.DEFAULT_COLORS.length]);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
else {
|
|
567
|
-
const d = widget.data;
|
|
568
|
-
this.editSeries = d.series.map((s) => ({ name: s.name, dataRaw: s.data.join(',') }));
|
|
569
|
-
this.editCategories = d.categories?.join(',') ?? '';
|
|
570
|
-
this.editColors = this.editColors.slice(0, this.editSeries.length);
|
|
571
|
-
while (this.editColors.length < this.editSeries.length) {
|
|
572
|
-
this.editColors.push(this.DEFAULT_COLORS[this.editColors.length % this.DEFAULT_COLORS.length]);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
this.editDialogOpen = true;
|
|
576
|
-
}
|
|
577
|
-
closeEditDialog() { this.editDialogOpen = false; this.editingWidget = undefined; }
|
|
578
|
-
getEditSeriesLabel(i) {
|
|
579
|
-
if (this.editWidgetType === 'DONUT')
|
|
580
|
-
return this.editSlices[i]?.label || `Slice ${i + 1}`;
|
|
581
|
-
return this.editSeries[i]?.name || `Series ${i + 1}`;
|
|
582
|
-
}
|
|
583
|
-
addEditSeries() {
|
|
584
|
-
this.editSeries.push({ name: `Series ${this.editSeries.length + 1}`, dataRaw: '' });
|
|
585
|
-
this.editColors.push(this.DEFAULT_COLORS[this.editColors.length % this.DEFAULT_COLORS.length]);
|
|
586
|
-
}
|
|
587
|
-
removeEditSeries(i) { this.editSeries.splice(i, 1); this.editColors.splice(i, 1); }
|
|
588
|
-
addEditSlice() {
|
|
589
|
-
this.editSlices.push({ label: `Slice ${this.editSlices.length + 1}`, value: null });
|
|
590
|
-
this.editColors.push(this.DEFAULT_COLORS[this.editColors.length % this.DEFAULT_COLORS.length]);
|
|
956
|
+
onPopOut(event, pageId) {
|
|
957
|
+
event.stopPropagation();
|
|
958
|
+
this.stateService.popOutPage(pageId);
|
|
591
959
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
}
|
|
604
|
-
else {
|
|
605
|
-
data = {
|
|
606
|
-
series: this.editSeries.map(s => ({
|
|
607
|
-
name: s.name || 'Series',
|
|
608
|
-
data: s.dataRaw.trim() ? parseNums(s.dataRaw) : [],
|
|
609
|
-
})),
|
|
610
|
-
categories: this.editCategories.trim()
|
|
611
|
-
? this.editCategories.split(',').map(c => c.trim())
|
|
612
|
-
: undefined,
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
this.stateService.updateWidgetMeta(this.editingWidget.id, {
|
|
616
|
-
title: this.editTitle.trim() || this.editingWidget.title,
|
|
617
|
-
colors: [...this.editColors],
|
|
618
|
-
cardColor: this.editCardColor,
|
|
619
|
-
data,
|
|
620
|
-
});
|
|
621
|
-
this.closeEditDialog();
|
|
960
|
+
serializeLayout() { return this.stateService.serializeLayout(); }
|
|
961
|
+
applyTheme() {
|
|
962
|
+
this.resolvedTheme = { ...DEFAULT_THEME, ...(this.theme ?? {}) };
|
|
963
|
+
this.wrapperStyles = {
|
|
964
|
+
'--dash-bg': this.resolvedTheme.backgroundColor,
|
|
965
|
+
'--dash-panel-bg': this.resolvedTheme.panelColor,
|
|
966
|
+
'--dash-card-bg': this.resolvedTheme.widgetCardColor,
|
|
967
|
+
'--dash-fore-color': this.resolvedTheme.foreColor,
|
|
968
|
+
'--dash-accent-color': this.resolvedTheme.accentColor,
|
|
969
|
+
'--dash-danger-color': this.resolvedTheme.dangerColor,
|
|
970
|
+
'--dash-font-family': this.resolvedTheme.fontFamily,
|
|
971
|
+
};
|
|
622
972
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
updateWidgetData(widgetId, data) {
|
|
626
|
-
this.stateService.updateWidgetData(widgetId, data);
|
|
973
|
+
updateActivePage() {
|
|
974
|
+
this.activePage = this.pages.find(p => p.id === this.activePageId);
|
|
627
975
|
}
|
|
628
|
-
serializeLayout() { return this.stateService.serializeLayout(); }
|
|
629
976
|
}
|
|
630
977
|
DashboardComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardComponent, deps: [{ token: DashboardStateService }], target: i0.ɵɵFactoryTarget.Component });
|
|
631
|
-
DashboardComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.10", type: DashboardComponent, selector: "app-dashboard", inputs: { initialLayout: "initialLayout", theme: "theme" }, usesOnChanges: true, ngImport: i0, template: `
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
<
|
|
639
|
-
<div
|
|
640
|
-
*ngFor="let page of pages"
|
|
641
|
-
class="tab"
|
|
642
|
-
[class.active]="page.id === activePageId"
|
|
643
|
-
(click)="onSelectPage(page.id)"
|
|
644
|
-
>
|
|
645
|
-
<span class="tab-name">{{ page.name }}</span>
|
|
646
|
-
<button class="tab-close" *ngIf="pages.length > 1" (click)="onRemovePage($event, page.id)">
|
|
647
|
-
<i class="la la-times"></i>
|
|
648
|
-
</button>
|
|
649
|
-
</div>
|
|
650
|
-
<button class="btn-add-page" (click)="onAddPage()" title="New workspace">
|
|
651
|
-
<i class="la la-plus"></i>
|
|
652
|
-
</button>
|
|
653
|
-
</div>
|
|
654
|
-
|
|
655
|
-
<!-- Add Widget button -->
|
|
656
|
-
<button class="btn-add-widget" (click)="openDialog()" title="Add widget">
|
|
657
|
-
<i class="la la-plus"></i>
|
|
658
|
-
<span>Add Widget</span>
|
|
659
|
-
</button>
|
|
978
|
+
DashboardComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.10", type: DashboardComponent, selector: "app-dashboard", inputs: { initialLayout: "initialLayout", theme: "theme" }, outputs: { addWidgetRequested: "addWidgetRequested", editWidgetRequested: "editWidgetRequested" }, queries: [{ propertyName: "cellTemplate", first: true, predicate: GridCellDirective, descendants: true, read: TemplateRef }], usesOnChanges: true, ngImport: i0, template: `
|
|
979
|
+
<!-- ══════════════════════════════════════════════════════════
|
|
980
|
+
POPPED-OUT MODE
|
|
981
|
+
══════════════════════════════════════════════════════════ -->
|
|
982
|
+
<ng-container *ngIf="isPoppedOut; else normalMode">
|
|
983
|
+
<div class="popout-wrapper" [ngStyle]="wrapperStyles">
|
|
984
|
+
<header class="popout-header">
|
|
985
|
+
<span class="popout-title">{{ activePage?.name }}</span>
|
|
660
986
|
</header>
|
|
661
|
-
|
|
662
|
-
<!-- ── Grid ── -->
|
|
663
987
|
<div class="grid-container">
|
|
664
|
-
<
|
|
665
|
-
<
|
|
988
|
+
<app-grid [widgets]="activePage?.widgets || []" (itemChanged)="onItemChanged($event)">
|
|
989
|
+
<ng-template gridCell let-widget="widget">
|
|
666
990
|
<app-widget-renderer
|
|
667
991
|
[widget]="widget"
|
|
668
|
-
[onRemoveWidget]="removeWidgetHandler"
|
|
669
992
|
[theme]="resolvedTheme"
|
|
670
|
-
(editRequested)="
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
993
|
+
(editRequested)="editWidgetRequested.emit($event)"
|
|
994
|
+
(removeRequested)="onRemoveWidget($event)"
|
|
995
|
+
>
|
|
996
|
+
<ng-container
|
|
997
|
+
*ngIf="cellTemplate"
|
|
998
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
999
|
+
[ngTemplateOutletContext]="{ widget: widget }">
|
|
1000
|
+
</ng-container>
|
|
1001
|
+
</app-widget-renderer>
|
|
1002
|
+
</ng-template>
|
|
1003
|
+
</app-grid>
|
|
674
1004
|
</div>
|
|
675
|
-
</main>
|
|
676
|
-
</div>
|
|
677
|
-
|
|
678
|
-
<!-- ── Add Widget Dialog ── -->
|
|
679
|
-
<div class="dialog-backdrop" *ngIf="dialogOpen" (click)="closeDialog()"></div>
|
|
680
|
-
<div class="dialog" *ngIf="dialogOpen">
|
|
681
|
-
<div class="dialog-header">
|
|
682
|
-
<span class="dialog-title">Add Widget</span>
|
|
683
|
-
<button class="dialog-close" (click)="closeDialog()"><i class="la la-times"></i></button>
|
|
684
1005
|
</div>
|
|
1006
|
+
</ng-container>
|
|
685
1007
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
<button
|
|
693
|
-
*ngFor="let t of widgetTypes"
|
|
694
|
-
class="type-btn"
|
|
695
|
-
[class.selected]="dialogType === t.value"
|
|
696
|
-
(click)="dialogType = t.value"
|
|
697
|
-
>
|
|
698
|
-
<i [class]="'la ' + t.icon"></i>
|
|
699
|
-
<span>{{ t.label }}</span>
|
|
700
|
-
</button>
|
|
701
|
-
</div>
|
|
702
|
-
</div>
|
|
1008
|
+
<!-- ══════════════════════════════════════════════════════════
|
|
1009
|
+
NORMAL MODE
|
|
1010
|
+
══════════════════════════════════════════════════════════ -->
|
|
1011
|
+
<ng-template #normalMode>
|
|
1012
|
+
<div class="dashboard-wrapper" [ngStyle]="wrapperStyles">
|
|
1013
|
+
<main class="main-content">
|
|
703
1014
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
<button class="btn-link" (click)="addSeries()"><i class="la la-plus"></i> Add series</button>
|
|
716
|
-
</div>
|
|
717
|
-
<div class="series-list">
|
|
718
|
-
<div class="series-row" *ngFor="let s of dialogSeries; let i = index">
|
|
719
|
-
<input class="field-input series-name" [(ngModel)]="s.name" placeholder="Series name" />
|
|
720
|
-
<input class="field-input series-data" [(ngModel)]="s.dataRaw"
|
|
721
|
-
placeholder="Comma-separated values, e.g. 10,25,40" />
|
|
722
|
-
<button class="btn-remove-series" *ngIf="dialogSeries.length > 1" (click)="removeSeries(i)">
|
|
723
|
-
<i class="la la-times"></i>
|
|
1015
|
+
<header class="dashboard-header">
|
|
1016
|
+
<div class="tabs-container">
|
|
1017
|
+
<div
|
|
1018
|
+
*ngFor="let page of pages"
|
|
1019
|
+
class="tab"
|
|
1020
|
+
[class.active]="page.id === activePageId"
|
|
1021
|
+
(click)="onSelectPage(page.id)"
|
|
1022
|
+
>
|
|
1023
|
+
<span class="tab-name">{{ page.name }}</span>
|
|
1024
|
+
<button class="tab-action tab-popout" (click)="onPopOut($event, page.id)" title="Open in new window">
|
|
1025
|
+
<i class="la la-external-link-alt"></i>
|
|
724
1026
|
</button>
|
|
725
|
-
|
|
726
|
-
</div>
|
|
727
|
-
</div>
|
|
728
|
-
|
|
729
|
-
<div class="field-group">
|
|
730
|
-
<label class="field-label">X-axis labels <span class="field-hint">(optional, comma-separated)</span></label>
|
|
731
|
-
<input class="field-input" [(ngModel)]="dialogCategories" placeholder="Jan,Feb,Mar,Apr" />
|
|
732
|
-
</div>
|
|
733
|
-
</ng-container>
|
|
734
|
-
|
|
735
|
-
<!-- Slices (DONUT) -->
|
|
736
|
-
<ng-container *ngIf="dialogType === 'DONUT'">
|
|
737
|
-
<div class="field-group">
|
|
738
|
-
<div class="field-row-header">
|
|
739
|
-
<label class="field-label">Slices</label>
|
|
740
|
-
<button class="btn-link" (click)="addSlice()"><i class="la la-plus"></i> Add slice</button>
|
|
741
|
-
</div>
|
|
742
|
-
<div class="series-list">
|
|
743
|
-
<div class="series-row" *ngFor="let sl of dialogSlices; let i = index">
|
|
744
|
-
<input class="field-input series-name" [(ngModel)]="sl.label" placeholder="Label" />
|
|
745
|
-
<input class="field-input series-data" [(ngModel)]="sl.value" type="number" placeholder="Value" />
|
|
746
|
-
<button class="btn-remove-series" *ngIf="dialogSlices.length > 1" (click)="removeSlice(i)">
|
|
1027
|
+
<button class="tab-action tab-close" *ngIf="pages.length > 1" (click)="onRemovePage($event, page.id)" title="Close">
|
|
747
1028
|
<i class="la la-times"></i>
|
|
748
1029
|
</button>
|
|
749
1030
|
</div>
|
|
1031
|
+
<button class="btn-add-page" (click)="onAddPage()" title="New workspace">
|
|
1032
|
+
<i class="la la-plus"></i>
|
|
1033
|
+
</button>
|
|
750
1034
|
</div>
|
|
751
|
-
</div>
|
|
752
|
-
</ng-container>
|
|
753
|
-
|
|
754
|
-
</div>
|
|
755
|
-
|
|
756
|
-
<div class="dialog-footer">
|
|
757
|
-
<button class="btn-cancel" (click)="closeDialog()">Cancel</button>
|
|
758
|
-
<button class="btn-confirm" (click)="confirmAddWidget()" [disabled]="!dialogTitle.trim()">
|
|
759
|
-
<i class="la la-check"></i> Add Widget
|
|
760
|
-
</button>
|
|
761
|
-
</div>
|
|
762
|
-
</div>
|
|
763
|
-
<!-- ── Edit Widget Dialog ── -->
|
|
764
|
-
<div class="dialog-backdrop" *ngIf="editDialogOpen" (click)="closeEditDialog()"></div>
|
|
765
|
-
<div class="dialog" *ngIf="editDialogOpen">
|
|
766
|
-
<div class="dialog-header">
|
|
767
|
-
<span class="dialog-title">Edit Widget</span>
|
|
768
|
-
<button class="dialog-close" (click)="closeEditDialog()"><i class="la la-times"></i></button>
|
|
769
|
-
</div>
|
|
770
|
-
|
|
771
|
-
<div class="dialog-body">
|
|
772
|
-
|
|
773
|
-
<!-- Title -->
|
|
774
|
-
<div class="field-group">
|
|
775
|
-
<label class="field-label">Title</label>
|
|
776
|
-
<input class="field-input" [(ngModel)]="editTitle" placeholder="Chart title" />
|
|
777
|
-
</div>
|
|
778
|
-
|
|
779
|
-
<!-- Card background -->
|
|
780
|
-
<div class="field-group">
|
|
781
|
-
<label class="field-label">Card color</label>
|
|
782
|
-
<div class="color-row">
|
|
783
|
-
<input type="color" class="color-swatch" [(ngModel)]="editCardColor" />
|
|
784
|
-
<input class="field-input" [(ngModel)]="editCardColor" placeholder="#2c2c2e" />
|
|
785
|
-
</div>
|
|
786
|
-
</div>
|
|
787
|
-
|
|
788
|
-
<!-- Series colors -->
|
|
789
|
-
<div class="field-group">
|
|
790
|
-
<label class="field-label">Series colors</label>
|
|
791
|
-
<div class="color-list">
|
|
792
|
-
<div class="color-item" *ngFor="let c of editColors; let i = index">
|
|
793
|
-
<input type="color" class="color-swatch" [(ngModel)]="editColors[i]" />
|
|
794
|
-
<input class="field-input color-hex" [(ngModel)]="editColors[i]" placeholder="#0a84ff" />
|
|
795
|
-
<span class="color-label">{{ getEditSeriesLabel(i) }}</span>
|
|
796
|
-
</div>
|
|
797
|
-
</div>
|
|
798
|
-
</div>
|
|
799
1035
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
</div>
|
|
807
|
-
<div class="series-list">
|
|
808
|
-
<div class="series-row" *ngFor="let s of editSeries; let i = index">
|
|
809
|
-
<input class="field-input series-name" [(ngModel)]="s.name" placeholder="Name" />
|
|
810
|
-
<input class="field-input series-data" [(ngModel)]="s.dataRaw" placeholder="10,20,30" />
|
|
811
|
-
<button class="btn-remove-series" *ngIf="editSeries.length > 1" (click)="removeEditSeries(i)">
|
|
812
|
-
<i class="la la-times"></i>
|
|
813
|
-
</button>
|
|
814
|
-
</div>
|
|
815
|
-
</div>
|
|
816
|
-
</div>
|
|
817
|
-
<div class="field-group">
|
|
818
|
-
<label class="field-label">X-axis labels <span class="field-hint">(optional, comma-separated)</span></label>
|
|
819
|
-
<input class="field-input" [(ngModel)]="editCategories" placeholder="Jan,Feb,Mar" />
|
|
820
|
-
</div>
|
|
821
|
-
</ng-container>
|
|
1036
|
+
<!-- Add Widget button — consumer decides what happens -->
|
|
1037
|
+
<button class="btn-add-widget" (click)="addWidgetRequested.emit()" title="Add widget">
|
|
1038
|
+
<i class="la la-plus"></i>
|
|
1039
|
+
<span>Add Widget</span>
|
|
1040
|
+
</button>
|
|
1041
|
+
</header>
|
|
822
1042
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1043
|
+
<div class="grid-container">
|
|
1044
|
+
<app-grid [widgets]="activePage?.widgets || []" (itemChanged)="onItemChanged($event)">
|
|
1045
|
+
<ng-template gridCell let-widget="widget">
|
|
1046
|
+
<app-widget-renderer
|
|
1047
|
+
[widget]="widget"
|
|
1048
|
+
[theme]="resolvedTheme"
|
|
1049
|
+
(editRequested)="editWidgetRequested.emit($event)"
|
|
1050
|
+
(removeRequested)="onRemoveWidget($event)"
|
|
1051
|
+
>
|
|
1052
|
+
<!-- Stamp consumer's cell template (if provided) inside the card body -->
|
|
1053
|
+
<ng-container
|
|
1054
|
+
*ngIf="cellTemplate"
|
|
1055
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
1056
|
+
[ngTemplateOutletContext]="{ widget: widget }">
|
|
1057
|
+
</ng-container>
|
|
1058
|
+
</app-widget-renderer>
|
|
1059
|
+
</ng-template>
|
|
1060
|
+
</app-grid>
|
|
839
1061
|
</div>
|
|
840
|
-
</ng-container>
|
|
841
1062
|
|
|
1063
|
+
</main>
|
|
842
1064
|
</div>
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
<button class="btn-cancel" (click)="closeEditDialog()">Cancel</button>
|
|
846
|
-
<button class="btn-confirm" (click)="confirmEditWidget()">
|
|
847
|
-
<i class="la la-check"></i> Save
|
|
848
|
-
</button>
|
|
849
|
-
</div>
|
|
850
|
-
</div>
|
|
851
|
-
`, isInline: true, styles: [":host{--dash-bg: #000000;--dash-panel-bg: #1c1c1e;--dash-card-bg: #2c2c2e;--dash-fore-color: #8e8e93;--dash-accent-color: #0a84ff;--dash-danger-color: #ff453a;--dash-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif}.dashboard-wrapper{display:flex;height:100vh;width:100vw;overflow:hidden;padding:16px;box-sizing:border-box;background:var(--dash-bg);color:var(--dash-fore-color);font-family:var(--dash-font-family)}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;border-radius:40px;padding:20px;background:var(--dash-panel-bg);box-shadow:0 10px 30px #00000080;border:1px solid rgba(255,255,255,.05)}.dashboard-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:12px}.tabs-container{display:flex;align-items:center;gap:8px;background:rgba(44,44,46,.6);backdrop-filter:blur(10px);border-radius:25px;padding:6px}.tab{display:flex;align-items:center;padding:8px 20px;border-radius:20px;cursor:pointer;font-size:14px;font-weight:500;color:var(--dash-fore-color);transition:all .3s cubic-bezier(.25,.8,.25,1)}.tab.active{background:#3a3a3c;color:#fff;box-shadow:0 2px 10px #0003}.tab:hover:not(.active){color:#e5e5ea;background:rgba(255,255,255,.05)}.tab-name{margin-right:8px}.tab-close{background:transparent;border:none;color:var(--dash-fore-color);padding:2px;font-size:12px;cursor:pointer;opacity:0;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:all .2s}.tab-close:hover{color:var(--dash-danger-color);background:rgba(255,69,58,.2)}.tab:hover .tab-close{opacity:1}.btn-add-page{background:transparent;border:none;color:var(--dash-fore-color);padding:8px 16px;cursor:pointer;border-radius:20px;transition:all .2s}.btn-add-page:hover{color:var(--dash-accent-color);background:rgba(10,132,255,.1)}.btn-add-widget{display:flex;align-items:center;gap:6px;background:var(--dash-accent-color);border:none;color:#fff;font-size:14px;font-weight:600;padding:10px 20px;border-radius:22px;cursor:pointer;white-space:nowrap;transition:opacity .2s,transform .15s;box-shadow:0 4px 14px #0a84ff66}.btn-add-widget:hover{opacity:.9;transform:translateY(-1px)}.btn-add-widget:active{transform:translateY(0)}.grid-container{flex:1;overflow:auto;border-radius:24px;min-height:0}gridster{background:transparent;height:100%!important}.dialog-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(6px);z-index:900}.dialog{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:901;width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 64px);display:flex;flex-direction:column;background:var(--dash-panel-bg);border-radius:28px;border:1px solid rgba(255,255,255,.1);box-shadow:0 24px 60px #000000b3;overflow:hidden}.dialog-header{display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px;border-bottom:1px solid rgba(255,255,255,.07)}.dialog-title{font-size:17px;font-weight:700;color:#fff}.dialog-close{background:rgba(255,255,255,.08);border:none;color:var(--dash-fore-color);width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;transition:all .2s}.dialog-close:hover{background:rgba(255,69,58,.25);color:var(--dash-danger-color)}.dialog-body{flex:1;overflow-y:auto;padding:20px 24px;display:flex;flex-direction:column;gap:20px}.field-group{display:flex;flex-direction:column;gap:8px}.field-label{font-size:13px;font-weight:600;color:var(--dash-fore-color);text-transform:uppercase;letter-spacing:.5px}.field-input{background:var(--dash-card-bg);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:10px 14px;color:#fff;font-size:14px;outline:none;width:100%;box-sizing:border-box}.field-input:focus{border-color:var(--dash-accent-color)}.type-picker{display:flex;gap:10px}.type-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 10px;background:var(--dash-card-bg);border:2px solid transparent;border-radius:16px;color:var(--dash-fore-color);font-size:12px;font-weight:600;cursor:pointer;transition:all .2s}.type-btn.selected{border-color:var(--dash-accent-color);color:var(--dash-accent-color);background:rgba(10,132,255,.12)}.btn-link{background:transparent;border:none;color:var(--dash-accent-color);font-size:13px;font-weight:600;cursor:pointer}.dialog-footer{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:16px 24px 22px;border-top:1px solid rgba(255,255,255,.07)}.btn-cancel{background:transparent;border:1px solid rgba(255,255,255,.12);color:var(--dash-fore-color);padding:10px 20px;border-radius:20px;cursor:pointer}.btn-confirm{background:var(--dash-accent-color);border:none;color:#fff;padding:10px 22px;border-radius:20px;font-weight:600;cursor:pointer}.btn-confirm:disabled{opacity:.4;cursor:not-allowed}\n"], dependencies: [{ kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i3$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i3$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4.GridsterComponent, selector: "gridster", inputs: ["options"] }, { kind: "component", type: i4.GridsterItemComponent, selector: "gridster-item", inputs: ["item"], outputs: ["itemInit", "itemChange", "itemResize"] }, { kind: "component", type: WidgetRendererComponent, selector: "app-widget-renderer", inputs: ["widget", "onRemoveWidget", "theme"], outputs: ["editRequested"] }] });
|
|
1065
|
+
</ng-template>
|
|
1066
|
+
`, isInline: true, styles: [":host{--dash-bg: #000000;--dash-panel-bg: #1c1c1e;--dash-card-bg: #2c2c2e;--dash-fore-color:#8e8e93;--dash-accent-color: #0a84ff;--dash-danger-color: #ff453a;--dash-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif}.dashboard-wrapper{display:flex;height:100vh;width:100vw;overflow:hidden;padding:16px;box-sizing:border-box;background:var(--dash-bg);color:var(--dash-fore-color);font-family:var(--dash-font-family)}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;border-radius:40px;padding:20px;background:var(--dash-panel-bg);box-shadow:0 10px 30px #00000080;border:1px solid rgba(255,255,255,.05)}.popout-wrapper{display:flex;flex-direction:column;height:100vh;width:100vw;overflow:hidden;padding:16px;box-sizing:border-box;background:var(--dash-bg);color:var(--dash-fore-color);font-family:var(--dash-font-family)}.popout-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding:10px 18px;background:var(--dash-panel-bg);border-radius:20px;border:1px solid rgba(255,255,255,.06);flex-shrink:0}.popout-title{font-size:16px;font-weight:700;color:#fff}.popout-wrapper .grid-container{flex:1;overflow:auto;border-radius:24px;min-height:0;background:var(--dash-panel-bg);padding:16px;border:1px solid rgba(255,255,255,.05)}.dashboard-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:12px}.tabs-container{display:flex;align-items:center;gap:8px;background:rgba(44,44,46,.6);backdrop-filter:blur(10px);border-radius:25px;padding:6px}.tab{display:flex;align-items:center;gap:4px;padding:8px 14px 8px 18px;border-radius:20px;cursor:pointer;font-size:14px;font-weight:500;color:var(--dash-fore-color);transition:all .3s cubic-bezier(.25,.8,.25,1)}.tab.active{background:#3a3a3c;color:#fff;box-shadow:0 2px 10px #0003}.tab:hover:not(.active){color:#e5e5ea;background:rgba(255,255,255,.05)}.tab-name{flex:1;white-space:nowrap}.tab-action{background:transparent;border:none;color:var(--dash-fore-color);padding:2px;font-size:11px;cursor:pointer;opacity:0;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}.tab:hover .tab-action{opacity:1}.tab-popout:hover{color:var(--dash-accent-color);background:rgba(10,132,255,.18)}.tab-close:hover{color:var(--dash-danger-color);background:rgba(255,69,58,.2)}.btn-add-page{background:transparent;border:none;color:var(--dash-fore-color);padding:8px 16px;cursor:pointer;border-radius:20px;transition:all .2s}.btn-add-page:hover{color:var(--dash-accent-color);background:rgba(10,132,255,.1)}.btn-add-widget{display:flex;align-items:center;gap:6px;background:var(--dash-accent-color);border:none;color:#fff;font-size:14px;font-weight:600;padding:10px 20px;border-radius:22px;cursor:pointer;white-space:nowrap;transition:opacity .2s,transform .15s;box-shadow:0 4px 14px #0a84ff66}.btn-add-widget:hover{opacity:.9;transform:translateY(-1px)}.btn-add-widget:active{transform:translateY(0)}.main-content .grid-container{flex:1;overflow:auto;border-radius:24px;min-height:0}app-grid{background:transparent;display:block;height:100%;width:100%}\n"], dependencies: [{ kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: WidgetRendererComponent, selector: "app-widget-renderer", inputs: ["widget", "theme"], outputs: ["editRequested", "removeRequested"] }, { kind: "component", type: CustomGridComponent, selector: "app-grid", inputs: ["widgets", "columns", "gap", "rowHeight", "minItemCols", "minItemRows"], outputs: ["itemChanged", "layoutChanged"] }, { kind: "directive", type: GridCellDirective, selector: "[gridCell]" }] });
|
|
852
1067
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardComponent, decorators: [{
|
|
853
1068
|
type: Component,
|
|
854
1069
|
args: [{ selector: 'app-dashboard', template: `
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
<
|
|
862
|
-
<div
|
|
863
|
-
*ngFor="let page of pages"
|
|
864
|
-
class="tab"
|
|
865
|
-
[class.active]="page.id === activePageId"
|
|
866
|
-
(click)="onSelectPage(page.id)"
|
|
867
|
-
>
|
|
868
|
-
<span class="tab-name">{{ page.name }}</span>
|
|
869
|
-
<button class="tab-close" *ngIf="pages.length > 1" (click)="onRemovePage($event, page.id)">
|
|
870
|
-
<i class="la la-times"></i>
|
|
871
|
-
</button>
|
|
872
|
-
</div>
|
|
873
|
-
<button class="btn-add-page" (click)="onAddPage()" title="New workspace">
|
|
874
|
-
<i class="la la-plus"></i>
|
|
875
|
-
</button>
|
|
876
|
-
</div>
|
|
877
|
-
|
|
878
|
-
<!-- Add Widget button -->
|
|
879
|
-
<button class="btn-add-widget" (click)="openDialog()" title="Add widget">
|
|
880
|
-
<i class="la la-plus"></i>
|
|
881
|
-
<span>Add Widget</span>
|
|
882
|
-
</button>
|
|
1070
|
+
<!-- ══════════════════════════════════════════════════════════
|
|
1071
|
+
POPPED-OUT MODE
|
|
1072
|
+
══════════════════════════════════════════════════════════ -->
|
|
1073
|
+
<ng-container *ngIf="isPoppedOut; else normalMode">
|
|
1074
|
+
<div class="popout-wrapper" [ngStyle]="wrapperStyles">
|
|
1075
|
+
<header class="popout-header">
|
|
1076
|
+
<span class="popout-title">{{ activePage?.name }}</span>
|
|
883
1077
|
</header>
|
|
884
|
-
|
|
885
|
-
<!-- ── Grid ── -->
|
|
886
1078
|
<div class="grid-container">
|
|
887
|
-
<
|
|
888
|
-
<
|
|
1079
|
+
<app-grid [widgets]="activePage?.widgets || []" (itemChanged)="onItemChanged($event)">
|
|
1080
|
+
<ng-template gridCell let-widget="widget">
|
|
889
1081
|
<app-widget-renderer
|
|
890
1082
|
[widget]="widget"
|
|
891
|
-
[onRemoveWidget]="removeWidgetHandler"
|
|
892
1083
|
[theme]="resolvedTheme"
|
|
893
|
-
(editRequested)="
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1084
|
+
(editRequested)="editWidgetRequested.emit($event)"
|
|
1085
|
+
(removeRequested)="onRemoveWidget($event)"
|
|
1086
|
+
>
|
|
1087
|
+
<ng-container
|
|
1088
|
+
*ngIf="cellTemplate"
|
|
1089
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
1090
|
+
[ngTemplateOutletContext]="{ widget: widget }">
|
|
1091
|
+
</ng-container>
|
|
1092
|
+
</app-widget-renderer>
|
|
1093
|
+
</ng-template>
|
|
1094
|
+
</app-grid>
|
|
897
1095
|
</div>
|
|
898
|
-
</main>
|
|
899
|
-
</div>
|
|
900
|
-
|
|
901
|
-
<!-- ── Add Widget Dialog ── -->
|
|
902
|
-
<div class="dialog-backdrop" *ngIf="dialogOpen" (click)="closeDialog()"></div>
|
|
903
|
-
<div class="dialog" *ngIf="dialogOpen">
|
|
904
|
-
<div class="dialog-header">
|
|
905
|
-
<span class="dialog-title">Add Widget</span>
|
|
906
|
-
<button class="dialog-close" (click)="closeDialog()"><i class="la la-times"></i></button>
|
|
907
1096
|
</div>
|
|
1097
|
+
</ng-container>
|
|
908
1098
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
<button
|
|
916
|
-
*ngFor="let t of widgetTypes"
|
|
917
|
-
class="type-btn"
|
|
918
|
-
[class.selected]="dialogType === t.value"
|
|
919
|
-
(click)="dialogType = t.value"
|
|
920
|
-
>
|
|
921
|
-
<i [class]="'la ' + t.icon"></i>
|
|
922
|
-
<span>{{ t.label }}</span>
|
|
923
|
-
</button>
|
|
924
|
-
</div>
|
|
925
|
-
</div>
|
|
1099
|
+
<!-- ══════════════════════════════════════════════════════════
|
|
1100
|
+
NORMAL MODE
|
|
1101
|
+
══════════════════════════════════════════════════════════ -->
|
|
1102
|
+
<ng-template #normalMode>
|
|
1103
|
+
<div class="dashboard-wrapper" [ngStyle]="wrapperStyles">
|
|
1104
|
+
<main class="main-content">
|
|
926
1105
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
<button class="btn-link" (click)="addSeries()"><i class="la la-plus"></i> Add series</button>
|
|
939
|
-
</div>
|
|
940
|
-
<div class="series-list">
|
|
941
|
-
<div class="series-row" *ngFor="let s of dialogSeries; let i = index">
|
|
942
|
-
<input class="field-input series-name" [(ngModel)]="s.name" placeholder="Series name" />
|
|
943
|
-
<input class="field-input series-data" [(ngModel)]="s.dataRaw"
|
|
944
|
-
placeholder="Comma-separated values, e.g. 10,25,40" />
|
|
945
|
-
<button class="btn-remove-series" *ngIf="dialogSeries.length > 1" (click)="removeSeries(i)">
|
|
946
|
-
<i class="la la-times"></i>
|
|
1106
|
+
<header class="dashboard-header">
|
|
1107
|
+
<div class="tabs-container">
|
|
1108
|
+
<div
|
|
1109
|
+
*ngFor="let page of pages"
|
|
1110
|
+
class="tab"
|
|
1111
|
+
[class.active]="page.id === activePageId"
|
|
1112
|
+
(click)="onSelectPage(page.id)"
|
|
1113
|
+
>
|
|
1114
|
+
<span class="tab-name">{{ page.name }}</span>
|
|
1115
|
+
<button class="tab-action tab-popout" (click)="onPopOut($event, page.id)" title="Open in new window">
|
|
1116
|
+
<i class="la la-external-link-alt"></i>
|
|
947
1117
|
</button>
|
|
948
|
-
|
|
949
|
-
</div>
|
|
950
|
-
</div>
|
|
951
|
-
|
|
952
|
-
<div class="field-group">
|
|
953
|
-
<label class="field-label">X-axis labels <span class="field-hint">(optional, comma-separated)</span></label>
|
|
954
|
-
<input class="field-input" [(ngModel)]="dialogCategories" placeholder="Jan,Feb,Mar,Apr" />
|
|
955
|
-
</div>
|
|
956
|
-
</ng-container>
|
|
957
|
-
|
|
958
|
-
<!-- Slices (DONUT) -->
|
|
959
|
-
<ng-container *ngIf="dialogType === 'DONUT'">
|
|
960
|
-
<div class="field-group">
|
|
961
|
-
<div class="field-row-header">
|
|
962
|
-
<label class="field-label">Slices</label>
|
|
963
|
-
<button class="btn-link" (click)="addSlice()"><i class="la la-plus"></i> Add slice</button>
|
|
964
|
-
</div>
|
|
965
|
-
<div class="series-list">
|
|
966
|
-
<div class="series-row" *ngFor="let sl of dialogSlices; let i = index">
|
|
967
|
-
<input class="field-input series-name" [(ngModel)]="sl.label" placeholder="Label" />
|
|
968
|
-
<input class="field-input series-data" [(ngModel)]="sl.value" type="number" placeholder="Value" />
|
|
969
|
-
<button class="btn-remove-series" *ngIf="dialogSlices.length > 1" (click)="removeSlice(i)">
|
|
1118
|
+
<button class="tab-action tab-close" *ngIf="pages.length > 1" (click)="onRemovePage($event, page.id)" title="Close">
|
|
970
1119
|
<i class="la la-times"></i>
|
|
971
1120
|
</button>
|
|
972
1121
|
</div>
|
|
1122
|
+
<button class="btn-add-page" (click)="onAddPage()" title="New workspace">
|
|
1123
|
+
<i class="la la-plus"></i>
|
|
1124
|
+
</button>
|
|
973
1125
|
</div>
|
|
974
|
-
</div>
|
|
975
|
-
</ng-container>
|
|
976
|
-
|
|
977
|
-
</div>
|
|
978
|
-
|
|
979
|
-
<div class="dialog-footer">
|
|
980
|
-
<button class="btn-cancel" (click)="closeDialog()">Cancel</button>
|
|
981
|
-
<button class="btn-confirm" (click)="confirmAddWidget()" [disabled]="!dialogTitle.trim()">
|
|
982
|
-
<i class="la la-check"></i> Add Widget
|
|
983
|
-
</button>
|
|
984
|
-
</div>
|
|
985
|
-
</div>
|
|
986
|
-
<!-- ── Edit Widget Dialog ── -->
|
|
987
|
-
<div class="dialog-backdrop" *ngIf="editDialogOpen" (click)="closeEditDialog()"></div>
|
|
988
|
-
<div class="dialog" *ngIf="editDialogOpen">
|
|
989
|
-
<div class="dialog-header">
|
|
990
|
-
<span class="dialog-title">Edit Widget</span>
|
|
991
|
-
<button class="dialog-close" (click)="closeEditDialog()"><i class="la la-times"></i></button>
|
|
992
|
-
</div>
|
|
993
|
-
|
|
994
|
-
<div class="dialog-body">
|
|
995
|
-
|
|
996
|
-
<!-- Title -->
|
|
997
|
-
<div class="field-group">
|
|
998
|
-
<label class="field-label">Title</label>
|
|
999
|
-
<input class="field-input" [(ngModel)]="editTitle" placeholder="Chart title" />
|
|
1000
|
-
</div>
|
|
1001
|
-
|
|
1002
|
-
<!-- Card background -->
|
|
1003
|
-
<div class="field-group">
|
|
1004
|
-
<label class="field-label">Card color</label>
|
|
1005
|
-
<div class="color-row">
|
|
1006
|
-
<input type="color" class="color-swatch" [(ngModel)]="editCardColor" />
|
|
1007
|
-
<input class="field-input" [(ngModel)]="editCardColor" placeholder="#2c2c2e" />
|
|
1008
|
-
</div>
|
|
1009
|
-
</div>
|
|
1010
|
-
|
|
1011
|
-
<!-- Series colors -->
|
|
1012
|
-
<div class="field-group">
|
|
1013
|
-
<label class="field-label">Series colors</label>
|
|
1014
|
-
<div class="color-list">
|
|
1015
|
-
<div class="color-item" *ngFor="let c of editColors; let i = index">
|
|
1016
|
-
<input type="color" class="color-swatch" [(ngModel)]="editColors[i]" />
|
|
1017
|
-
<input class="field-input color-hex" [(ngModel)]="editColors[i]" placeholder="#0a84ff" />
|
|
1018
|
-
<span class="color-label">{{ getEditSeriesLabel(i) }}</span>
|
|
1019
|
-
</div>
|
|
1020
|
-
</div>
|
|
1021
|
-
</div>
|
|
1022
1126
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
</div>
|
|
1030
|
-
<div class="series-list">
|
|
1031
|
-
<div class="series-row" *ngFor="let s of editSeries; let i = index">
|
|
1032
|
-
<input class="field-input series-name" [(ngModel)]="s.name" placeholder="Name" />
|
|
1033
|
-
<input class="field-input series-data" [(ngModel)]="s.dataRaw" placeholder="10,20,30" />
|
|
1034
|
-
<button class="btn-remove-series" *ngIf="editSeries.length > 1" (click)="removeEditSeries(i)">
|
|
1035
|
-
<i class="la la-times"></i>
|
|
1036
|
-
</button>
|
|
1037
|
-
</div>
|
|
1038
|
-
</div>
|
|
1039
|
-
</div>
|
|
1040
|
-
<div class="field-group">
|
|
1041
|
-
<label class="field-label">X-axis labels <span class="field-hint">(optional, comma-separated)</span></label>
|
|
1042
|
-
<input class="field-input" [(ngModel)]="editCategories" placeholder="Jan,Feb,Mar" />
|
|
1043
|
-
</div>
|
|
1044
|
-
</ng-container>
|
|
1127
|
+
<!-- Add Widget button — consumer decides what happens -->
|
|
1128
|
+
<button class="btn-add-widget" (click)="addWidgetRequested.emit()" title="Add widget">
|
|
1129
|
+
<i class="la la-plus"></i>
|
|
1130
|
+
<span>Add Widget</span>
|
|
1131
|
+
</button>
|
|
1132
|
+
</header>
|
|
1045
1133
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1134
|
+
<div class="grid-container">
|
|
1135
|
+
<app-grid [widgets]="activePage?.widgets || []" (itemChanged)="onItemChanged($event)">
|
|
1136
|
+
<ng-template gridCell let-widget="widget">
|
|
1137
|
+
<app-widget-renderer
|
|
1138
|
+
[widget]="widget"
|
|
1139
|
+
[theme]="resolvedTheme"
|
|
1140
|
+
(editRequested)="editWidgetRequested.emit($event)"
|
|
1141
|
+
(removeRequested)="onRemoveWidget($event)"
|
|
1142
|
+
>
|
|
1143
|
+
<!-- Stamp consumer's cell template (if provided) inside the card body -->
|
|
1144
|
+
<ng-container
|
|
1145
|
+
*ngIf="cellTemplate"
|
|
1146
|
+
[ngTemplateOutlet]="cellTemplate"
|
|
1147
|
+
[ngTemplateOutletContext]="{ widget: widget }">
|
|
1148
|
+
</ng-container>
|
|
1149
|
+
</app-widget-renderer>
|
|
1150
|
+
</ng-template>
|
|
1151
|
+
</app-grid>
|
|
1062
1152
|
</div>
|
|
1063
|
-
</ng-container>
|
|
1064
|
-
|
|
1065
|
-
</div>
|
|
1066
1153
|
|
|
1067
|
-
|
|
1068
|
-
<button class="btn-cancel" (click)="closeEditDialog()">Cancel</button>
|
|
1069
|
-
<button class="btn-confirm" (click)="confirmEditWidget()">
|
|
1070
|
-
<i class="la la-check"></i> Save
|
|
1071
|
-
</button>
|
|
1154
|
+
</main>
|
|
1072
1155
|
</div>
|
|
1073
|
-
</
|
|
1074
|
-
`, styles: [":host{--dash-bg: #000000;--dash-panel-bg: #1c1c1e;--dash-card-bg: #2c2c2e;--dash-fore-color
|
|
1156
|
+
</ng-template>
|
|
1157
|
+
`, styles: [":host{--dash-bg: #000000;--dash-panel-bg: #1c1c1e;--dash-card-bg: #2c2c2e;--dash-fore-color:#8e8e93;--dash-accent-color: #0a84ff;--dash-danger-color: #ff453a;--dash-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif}.dashboard-wrapper{display:flex;height:100vh;width:100vw;overflow:hidden;padding:16px;box-sizing:border-box;background:var(--dash-bg);color:var(--dash-fore-color);font-family:var(--dash-font-family)}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;border-radius:40px;padding:20px;background:var(--dash-panel-bg);box-shadow:0 10px 30px #00000080;border:1px solid rgba(255,255,255,.05)}.popout-wrapper{display:flex;flex-direction:column;height:100vh;width:100vw;overflow:hidden;padding:16px;box-sizing:border-box;background:var(--dash-bg);color:var(--dash-fore-color);font-family:var(--dash-font-family)}.popout-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding:10px 18px;background:var(--dash-panel-bg);border-radius:20px;border:1px solid rgba(255,255,255,.06);flex-shrink:0}.popout-title{font-size:16px;font-weight:700;color:#fff}.popout-wrapper .grid-container{flex:1;overflow:auto;border-radius:24px;min-height:0;background:var(--dash-panel-bg);padding:16px;border:1px solid rgba(255,255,255,.05)}.dashboard-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:12px}.tabs-container{display:flex;align-items:center;gap:8px;background:rgba(44,44,46,.6);backdrop-filter:blur(10px);border-radius:25px;padding:6px}.tab{display:flex;align-items:center;gap:4px;padding:8px 14px 8px 18px;border-radius:20px;cursor:pointer;font-size:14px;font-weight:500;color:var(--dash-fore-color);transition:all .3s cubic-bezier(.25,.8,.25,1)}.tab.active{background:#3a3a3c;color:#fff;box-shadow:0 2px 10px #0003}.tab:hover:not(.active){color:#e5e5ea;background:rgba(255,255,255,.05)}.tab-name{flex:1;white-space:nowrap}.tab-action{background:transparent;border:none;color:var(--dash-fore-color);padding:2px;font-size:11px;cursor:pointer;opacity:0;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}.tab:hover .tab-action{opacity:1}.tab-popout:hover{color:var(--dash-accent-color);background:rgba(10,132,255,.18)}.tab-close:hover{color:var(--dash-danger-color);background:rgba(255,69,58,.2)}.btn-add-page{background:transparent;border:none;color:var(--dash-fore-color);padding:8px 16px;cursor:pointer;border-radius:20px;transition:all .2s}.btn-add-page:hover{color:var(--dash-accent-color);background:rgba(10,132,255,.1)}.btn-add-widget{display:flex;align-items:center;gap:6px;background:var(--dash-accent-color);border:none;color:#fff;font-size:14px;font-weight:600;padding:10px 20px;border-radius:22px;cursor:pointer;white-space:nowrap;transition:opacity .2s,transform .15s;box-shadow:0 4px 14px #0a84ff66}.btn-add-widget:hover{opacity:.9;transform:translateY(-1px)}.btn-add-widget:active{transform:translateY(0)}.main-content .grid-container{flex:1;overflow:auto;border-radius:24px;min-height:0}app-grid{background:transparent;display:block;height:100%;width:100%}\n"] }]
|
|
1075
1158
|
}], ctorParameters: function () { return [{ type: DashboardStateService }]; }, propDecorators: { initialLayout: [{
|
|
1076
1159
|
type: Input
|
|
1077
1160
|
}], theme: [{
|
|
1078
1161
|
type: Input
|
|
1162
|
+
}], addWidgetRequested: [{
|
|
1163
|
+
type: Output
|
|
1164
|
+
}], editWidgetRequested: [{
|
|
1165
|
+
type: Output
|
|
1166
|
+
}], cellTemplate: [{
|
|
1167
|
+
type: ContentChild,
|
|
1168
|
+
args: [GridCellDirective, { read: TemplateRef }]
|
|
1079
1169
|
}] } });
|
|
1080
1170
|
|
|
1081
1171
|
class DashboardModule {
|
|
1082
1172
|
}
|
|
1083
1173
|
DashboardModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
1084
1174
|
DashboardModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "15.2.10", ngImport: i0, type: DashboardModule, declarations: [DashboardComponent,
|
|
1085
|
-
WidgetRendererComponent
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
GridsterModule,
|
|
1093
|
-
NgApexchartsModule] });
|
|
1175
|
+
WidgetRendererComponent,
|
|
1176
|
+
CustomGridComponent,
|
|
1177
|
+
GridCellDirective], imports: [CommonModule], exports: [DashboardComponent,
|
|
1178
|
+
WidgetRendererComponent,
|
|
1179
|
+
CustomGridComponent,
|
|
1180
|
+
GridCellDirective] });
|
|
1181
|
+
DashboardModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardModule, providers: [DashboardStateService], imports: [CommonModule] });
|
|
1094
1182
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: DashboardModule, decorators: [{
|
|
1095
1183
|
type: NgModule,
|
|
1096
1184
|
args: [{
|
|
1097
1185
|
declarations: [
|
|
1098
1186
|
DashboardComponent,
|
|
1099
|
-
WidgetRendererComponent
|
|
1187
|
+
WidgetRendererComponent,
|
|
1188
|
+
CustomGridComponent,
|
|
1189
|
+
GridCellDirective,
|
|
1100
1190
|
],
|
|
1101
1191
|
imports: [
|
|
1102
1192
|
CommonModule,
|
|
1103
|
-
FormsModule,
|
|
1104
|
-
GridsterModule,
|
|
1105
|
-
NgApexchartsModule
|
|
1106
1193
|
],
|
|
1107
1194
|
providers: [DashboardStateService],
|
|
1108
1195
|
exports: [
|
|
1109
1196
|
DashboardComponent,
|
|
1110
|
-
WidgetRendererComponent
|
|
1197
|
+
WidgetRendererComponent,
|
|
1198
|
+
CustomGridComponent,
|
|
1199
|
+
GridCellDirective,
|
|
1111
1200
|
],
|
|
1112
|
-
schemas: [NO_ERRORS_SCHEMA]
|
|
1113
1201
|
}]
|
|
1114
1202
|
}] });
|
|
1115
1203
|
/**
|
|
1116
|
-
* Root module for local demo / testing purposes.
|
|
1117
|
-
*
|
|
1204
|
+
* Root module for local demo / testing purposes only.
|
|
1205
|
+
* Library consumers import DashboardModule, not AppModule.
|
|
1118
1206
|
*/
|
|
1119
1207
|
class AppModule {
|
|
1120
1208
|
}
|
|
1121
1209
|
AppModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: AppModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
1122
1210
|
AppModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "15.2.10", ngImport: i0, type: AppModule, bootstrap: [DashboardComponent], imports: [BrowserModule, DashboardModule] });
|
|
1123
|
-
AppModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: AppModule, imports: [BrowserModule,
|
|
1124
|
-
DashboardModule] });
|
|
1211
|
+
AppModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: AppModule, imports: [BrowserModule, DashboardModule] });
|
|
1125
1212
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.10", ngImport: i0, type: AppModule, decorators: [{
|
|
1126
1213
|
type: NgModule,
|
|
1127
1214
|
args: [{
|
|
1128
|
-
imports: [
|
|
1129
|
-
|
|
1130
|
-
DashboardModule
|
|
1131
|
-
],
|
|
1132
|
-
bootstrap: [DashboardComponent]
|
|
1215
|
+
imports: [BrowserModule, DashboardModule],
|
|
1216
|
+
bootstrap: [DashboardComponent],
|
|
1133
1217
|
}]
|
|
1134
1218
|
}] });
|
|
1135
1219
|
|
|
1136
1220
|
/*
|
|
1137
|
-
* Public API Surface of
|
|
1221
|
+
* Public API Surface of @ogidor/dashboard
|
|
1138
1222
|
*/
|
|
1139
1223
|
|
|
1140
1224
|
/**
|
|
1141
1225
|
* Generated bundle index. Do not edit.
|
|
1142
1226
|
*/
|
|
1143
1227
|
|
|
1144
|
-
export { AppModule, DashboardComponent, DashboardModule, DashboardStateService, WidgetRendererComponent };
|
|
1228
|
+
export { AppModule, CustomGridComponent, DashboardComponent, DashboardModule, DashboardStateService, GridCellDirective, WidgetRendererComponent };
|
|
1145
1229
|
//# sourceMappingURL=ogidor-dashboard.mjs.map
|