Initial commit: dashboard-elements-ui library
Angular library providing dashboard components including grid layout, drag-and-drop widgets, resize handles, toolbar, config panel, layout presets, and persistence services.
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Demo app
|
||||||
|
demo/node_modules/
|
||||||
|
demo/dist/
|
||||||
|
demo/.angular/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
35
demo/angular.json
Normal file
35
demo/angular.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"demo": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/demo",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": ["src/favicon.ico", "src/assets"],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"preserveSymlinks": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "demo:build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13963
demo/package-lock.json
generated
Normal file
13963
demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
demo/package.json
Normal file
29
demo/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-elements-ui-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "ng serve --port 4200",
|
||||||
|
"build": "ng build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^19.1.0",
|
||||||
|
"@angular/common": "^19.1.0",
|
||||||
|
"@angular/compiler": "^19.1.0",
|
||||||
|
"@angular/core": "^19.1.0",
|
||||||
|
"@angular/forms": "^19.1.0",
|
||||||
|
"@angular/platform-browser": "^19.1.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.1.0",
|
||||||
|
"@sda/base-ui": "file:../../base-ui",
|
||||||
|
"@sda/dashboard-elements-ui": "file:../dist",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "^0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.1.0",
|
||||||
|
"@angular/cli": "^19.1.0",
|
||||||
|
"@angular/compiler-cli": "^19.1.0",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
462
demo/src/app/app.component.ts
Normal file
462
demo/src/app/app.component.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
DbDashboardComponent,
|
||||||
|
DbWidgetContentDefDirective,
|
||||||
|
type DbDashboard,
|
||||||
|
type DbWidgetType,
|
||||||
|
} from '@sda/dashboard-elements-ui';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, DbDashboardComponent, DbWidgetContentDefDirective],
|
||||||
|
template: `
|
||||||
|
<div class="demo-container">
|
||||||
|
<db-dashboard
|
||||||
|
[dashboard]="dashboard()"
|
||||||
|
[widgetTypes]="widgetTypes"
|
||||||
|
[showToolbar]="true"
|
||||||
|
title="Analytics Dashboard"
|
||||||
|
(widgetAdd)="onWidgetAdd($event)"
|
||||||
|
(widgetRemove)="onWidgetRemove($event)"
|
||||||
|
(widgetMove)="onWidgetMove($event)"
|
||||||
|
(dashboardSave)="onSave($event)"
|
||||||
|
>
|
||||||
|
<ng-template dbWidgetContentDef="stats" let-widget>
|
||||||
|
<div class="widget-stats">
|
||||||
|
<div class="stat-value">{{ widget.config?.['value'] ?? 0 }}</div>
|
||||||
|
<div class="stat-label">{{ widget.config?.['label'] ?? 'Metric' }}</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template dbWidgetContentDef="chart" let-widget>
|
||||||
|
<div class="widget-chart">
|
||||||
|
<div class="chart-title">{{ widget.title }}</div>
|
||||||
|
<div class="chart-placeholder">
|
||||||
|
<svg viewBox="0 0 400 200" class="chart-svg">
|
||||||
|
<polyline
|
||||||
|
points="0,150 50,120 100,140 150,80 200,100 250,60 300,90 350,40 400,70"
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="0,150 50,120 100,140 150,80 200,100 250,60 300,90 350,40 400,70"
|
||||||
|
fill="url(#gradient)"
|
||||||
|
opacity="0.2"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:0.8" />
|
||||||
|
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template dbWidgetContentDef="table" let-widget>
|
||||||
|
<div class="widget-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Sales</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Product A</strong></td>
|
||||||
|
<td>$12,450</td>
|
||||||
|
<td><span class="status status--success">Active</span></td>
|
||||||
|
<td class="positive">+12.5%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Product B</strong></td>
|
||||||
|
<td>$8,320</td>
|
||||||
|
<td><span class="status status--success">Active</span></td>
|
||||||
|
<td class="positive">+8.3%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Product C</strong></td>
|
||||||
|
<td>$5,680</td>
|
||||||
|
<td><span class="status status--warning">Pending</span></td>
|
||||||
|
<td class="negative">-3.2%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Product D</strong></td>
|
||||||
|
<td>$4,120</td>
|
||||||
|
<td><span class="status status--success">Active</span></td>
|
||||||
|
<td class="positive">+15.7%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template dbWidgetContentDef="list" let-widget>
|
||||||
|
<div class="widget-list">
|
||||||
|
@for (item of sampleListItems; track item.initial) {
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-item__icon">
|
||||||
|
<div class="avatar">{{ item.initial }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-item__content">
|
||||||
|
<div class="list-item__title">{{ item.title }}</div>
|
||||||
|
<div class="list-item__subtitle">{{ item.subtitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-item__meta">{{ item.time }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</db-dashboard>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.demo-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Widget */
|
||||||
|
.widget-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3b82f6;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Widget */
|
||||||
|
.widget-chart {
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Widget */
|
||||||
|
.widget-table {
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #6b7280;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status--warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Widget */
|
||||||
|
.widget-list {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
transition: background 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item__title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item__subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class AppComponent {
|
||||||
|
dashboard = signal<DbDashboard>({
|
||||||
|
id: 'demo-dashboard',
|
||||||
|
title: 'Analytics Dashboard',
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
breakpoint: 'lg',
|
||||||
|
columns: 12,
|
||||||
|
rowHeight: 80,
|
||||||
|
gap: 16,
|
||||||
|
widgets: [
|
||||||
|
{ id: 'widget-1', col: 1, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
{ id: 'widget-2', col: 4, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
{ id: 'widget-3', col: 7, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
{ id: 'widget-4', col: 10, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
{ id: 'widget-5', col: 1, row: 3, colSpan: 8, rowSpan: 4 },
|
||||||
|
{ id: 'widget-6', col: 9, row: 3, colSpan: 4, rowSpan: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
id: 'widget-1',
|
||||||
|
type: 'stats',
|
||||||
|
title: 'Total Users',
|
||||||
|
icon: 'users',
|
||||||
|
layout: { id: 'widget-1', col: 1, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
config: { value: '2,845', label: 'Total Users' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'widget-2',
|
||||||
|
type: 'stats',
|
||||||
|
title: 'Revenue',
|
||||||
|
icon: 'dollar-sign',
|
||||||
|
layout: { id: 'widget-2', col: 4, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
config: { value: '$54.2K', label: 'Monthly Revenue' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'widget-3',
|
||||||
|
type: 'stats',
|
||||||
|
title: 'Conversion',
|
||||||
|
icon: 'trending-up',
|
||||||
|
layout: { id: 'widget-3', col: 7, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
config: { value: '28.5%', label: 'Conversion Rate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'widget-4',
|
||||||
|
type: 'stats',
|
||||||
|
title: 'Active Now',
|
||||||
|
icon: 'activity',
|
||||||
|
layout: { id: 'widget-4', col: 10, row: 1, colSpan: 3, rowSpan: 2 },
|
||||||
|
config: { value: '124', label: 'Active Sessions' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'widget-5',
|
||||||
|
type: 'chart',
|
||||||
|
title: 'Revenue Trend',
|
||||||
|
icon: 'bar-chart-2',
|
||||||
|
layout: { id: 'widget-5', col: 1, row: 3, colSpan: 8, rowSpan: 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'widget-6',
|
||||||
|
type: 'list',
|
||||||
|
title: 'Recent Activity',
|
||||||
|
icon: 'activity',
|
||||||
|
layout: { id: 'widget-6', col: 9, row: 3, colSpan: 4, rowSpan: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTypes: DbWidgetType[] = [
|
||||||
|
{
|
||||||
|
type: 'stats',
|
||||||
|
label: 'Stats Card',
|
||||||
|
icon: 'bar-chart',
|
||||||
|
description: 'Display key metrics and statistics',
|
||||||
|
category: 'analytics',
|
||||||
|
defaultLayout: {
|
||||||
|
colSpan: 3,
|
||||||
|
rowSpan: 2,
|
||||||
|
minColSpan: 2,
|
||||||
|
minRowSpan: 2,
|
||||||
|
maxColSpan: 4,
|
||||||
|
maxRowSpan: 3,
|
||||||
|
},
|
||||||
|
component: class {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'chart',
|
||||||
|
label: 'Chart',
|
||||||
|
icon: 'line-chart',
|
||||||
|
description: 'Visualize data with charts',
|
||||||
|
category: 'analytics',
|
||||||
|
defaultLayout: {
|
||||||
|
colSpan: 6,
|
||||||
|
rowSpan: 4,
|
||||||
|
minColSpan: 4,
|
||||||
|
minRowSpan: 3,
|
||||||
|
},
|
||||||
|
component: class {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
label: 'Data Table',
|
||||||
|
icon: 'table',
|
||||||
|
description: 'Display tabular data',
|
||||||
|
category: 'data',
|
||||||
|
defaultLayout: {
|
||||||
|
colSpan: 6,
|
||||||
|
rowSpan: 4,
|
||||||
|
minColSpan: 4,
|
||||||
|
minRowSpan: 3,
|
||||||
|
},
|
||||||
|
component: class {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
label: 'Activity List',
|
||||||
|
icon: 'list',
|
||||||
|
description: 'Show recent activities or notifications',
|
||||||
|
category: 'data',
|
||||||
|
defaultLayout: {
|
||||||
|
colSpan: 4,
|
||||||
|
rowSpan: 4,
|
||||||
|
minColSpan: 3,
|
||||||
|
minRowSpan: 3,
|
||||||
|
},
|
||||||
|
component: class {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
sampleListItems = [
|
||||||
|
{ initial: 'JD', title: 'John Doe completed a task', subtitle: 'Project Alpha', time: '2m ago' },
|
||||||
|
{ initial: 'SM', title: 'Sarah Miller uploaded a file', subtitle: 'Document.pdf', time: '15m ago' },
|
||||||
|
{ initial: 'RJ', title: 'Robert Johnson commented', subtitle: 'Great work on this!', time: '1h ago' },
|
||||||
|
{ initial: 'EW', title: 'Emily White created an issue', subtitle: 'Bug #234', time: '2h ago' },
|
||||||
|
{ initial: 'MC', title: 'Michael Chen merged PR #45', subtitle: 'Feature: Add dashboard', time: '3h ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
onWidgetAdd(event: any) {
|
||||||
|
console.log('Widget added:', event);
|
||||||
|
const newWidgets = [...this.dashboard().widgets, event.widget];
|
||||||
|
this.dashboard.update(d => ({ ...d, widgets: newWidgets }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onWidgetRemove(event: any) {
|
||||||
|
console.log('Widget removed:', event);
|
||||||
|
const filtered = this.dashboard().widgets.filter((w: any) => w.id !== event.widget.id);
|
||||||
|
this.dashboard.update(d => ({ ...d, widgets: filtered }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onWidgetMove(event: any) {
|
||||||
|
console.log('Widget moved:', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(event: any) {
|
||||||
|
console.log('Dashboard saved:', event);
|
||||||
|
alert('Dashboard saved successfully!');
|
||||||
|
}
|
||||||
|
}
|
||||||
0
demo/src/favicon.ico
Normal file
0
demo/src/favicon.ico
Normal file
13
demo/src/index.html
Normal file
13
demo/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Dashboard Elements UI Demo</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
demo/src/main.ts
Normal file
6
demo/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, {
|
||||||
|
providers: [],
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
18
demo/src/styles.scss
Normal file
18
demo/src/styles.scss
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@use '@sda/dashboard-elements-ui/src/styles' as db;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: #f5f5f7;
|
||||||
|
color: #111827;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
9
demo/tsconfig.app.json
Normal file
9
demo/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
29
demo/tsconfig.json
Normal file
29
demo/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022", "dom"],
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"preserveSymlinks": true
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ng-package.json
Normal file
12
ng-package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
|
||||||
|
"lib": {
|
||||||
|
"entryFile": "src/index.ts",
|
||||||
|
"styleIncludePaths": ["src/styles"]
|
||||||
|
},
|
||||||
|
"dest": "dist",
|
||||||
|
"deleteDestPath": true,
|
||||||
|
"assets": [
|
||||||
|
"src/styles/**/*.scss"
|
||||||
|
]
|
||||||
|
}
|
||||||
3849
package-lock.json
generated
Normal file
3849
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@sda/dashboard-elements-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Angular components for dashboard layout builder with drag-and-drop, CSS Grid, widget management, and layout presets powered by @sda/base-ui",
|
||||||
|
"keywords": [
|
||||||
|
"angular",
|
||||||
|
"dashboard",
|
||||||
|
"layout",
|
||||||
|
"drag-and-drop",
|
||||||
|
"grid",
|
||||||
|
"widgets",
|
||||||
|
"components",
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"build": "ng-packagr -p ng-package.json",
|
||||||
|
"build:dev": "./build-for-dev.sh"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^19.0.0",
|
||||||
|
"@angular/core": "^19.0.0",
|
||||||
|
"@sda/base-ui": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/common": "^19.1.0",
|
||||||
|
"@angular/compiler": "^19.1.0",
|
||||||
|
"@angular/compiler-cli": "^19.1.0",
|
||||||
|
"@angular/core": "^19.1.0",
|
||||||
|
"@angular/forms": "^19.2.18",
|
||||||
|
"@sda/base-ui": "file:../base-ui",
|
||||||
|
"ng-packagr": "^19.1.0",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<div class="db-config-panel__header">
|
||||||
|
<span class="db-config-panel__title">Widget Settings</span>
|
||||||
|
<ui-button variant="ghost" size="sm" (click)="close.emit()">
|
||||||
|
<ui-icon name="x" [size]="16" />
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (widget(); as w) {
|
||||||
|
<div class="db-config-panel__body">
|
||||||
|
<!-- Dynamic config component from registry -->
|
||||||
|
<ng-template #configHost />
|
||||||
|
|
||||||
|
<!-- Default config UI when no custom config component -->
|
||||||
|
<div class="db-config-panel__section">
|
||||||
|
<label class="db-config-panel__label">Title</label>
|
||||||
|
<ui-input
|
||||||
|
[(ngModel)]="titleValue"
|
||||||
|
(ngModelChange)="onTitleChange($event)"
|
||||||
|
placeholder="Widget title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-config-panel__section">
|
||||||
|
<label class="db-config-panel__label">Layout</label>
|
||||||
|
<div class="db-config-panel__grid">
|
||||||
|
<div class="db-config-panel__field">
|
||||||
|
<span class="db-config-panel__field-label">Column</span>
|
||||||
|
<ui-input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="colValue"
|
||||||
|
(ngModelChange)="onLayoutChange('col', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="db-config-panel__field">
|
||||||
|
<span class="db-config-panel__field-label">Row</span>
|
||||||
|
<ui-input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="rowValue"
|
||||||
|
(ngModelChange)="onLayoutChange('row', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="db-config-panel__field">
|
||||||
|
<span class="db-config-panel__field-label">Col Span</span>
|
||||||
|
<ui-input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="colSpanValue"
|
||||||
|
(ngModelChange)="onLayoutChange('colSpan', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="db-config-panel__field">
|
||||||
|
<span class="db-config-panel__field-label">Row Span</span>
|
||||||
|
<ui-input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="rowSpanValue"
|
||||||
|
(ngModelChange)="onLayoutChange('rowSpan', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-panel-slide('right');
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--db-config-panel-width);
|
||||||
|
background: var(--db-config-panel-bg);
|
||||||
|
border-left: 1px solid var(--db-config-panel-border);
|
||||||
|
box-shadow: var(--db-config-panel-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 50;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__header {
|
||||||
|
@include db-flex-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--db-config-panel-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__title {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
@include db-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__section {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-config-panel__field-label {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
}
|
||||||
81
src/components/db-config-panel/db-config-panel.component.ts
Normal file
81
src/components/db-config-panel/db-config-panel.component.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, input, output, inject,
|
||||||
|
viewChild, ViewContainerRef, effect, signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
IconComponent, ButtonComponent, InputComponent, SelectComponent, TabsComponent,
|
||||||
|
} from '@sda/base-ui';
|
||||||
|
import type { DbWidget } from '../../types/dashboard.types';
|
||||||
|
import { DashboardRegistryService } from '../../services/dashboard-registry.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-config-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, IconComponent, ButtonComponent,
|
||||||
|
InputComponent, SelectComponent, TabsComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './db-config-panel.component.html',
|
||||||
|
styleUrl: './db-config-panel.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-config-panel',
|
||||||
|
'[class.db-panel--open]': 'open()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbConfigPanelComponent {
|
||||||
|
private registryService = inject(DashboardRegistryService);
|
||||||
|
private configHost = viewChild('configHost', { read: ViewContainerRef });
|
||||||
|
|
||||||
|
readonly widget = input<DbWidget | null>(null);
|
||||||
|
readonly open = input(false);
|
||||||
|
|
||||||
|
readonly configChange = output<{ key: string; value: unknown }>();
|
||||||
|
readonly close = output<void>();
|
||||||
|
|
||||||
|
protected titleValue = signal('');
|
||||||
|
protected colValue = signal(0);
|
||||||
|
protected rowValue = signal(0);
|
||||||
|
protected colSpanValue = signal(0);
|
||||||
|
protected rowSpanValue = signal(0);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Sync widget values to local signals
|
||||||
|
effect(() => {
|
||||||
|
const w = this.widget();
|
||||||
|
if (w) {
|
||||||
|
this.titleValue.set(w.title);
|
||||||
|
this.colValue.set(w.layout.col);
|
||||||
|
this.rowValue.set(w.layout.row);
|
||||||
|
this.colSpanValue.set(w.layout.colSpan);
|
||||||
|
this.rowSpanValue.set(w.layout.rowSpan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const host = this.configHost();
|
||||||
|
const widget = this.widget();
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
host.clear();
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
const widgetType = this.registryService.getType(widget.type);
|
||||||
|
if (widgetType?.configComponent) {
|
||||||
|
const ref = host.createComponent(widgetType.configComponent);
|
||||||
|
const instance = ref.instance as Record<string, unknown>;
|
||||||
|
if ('widget' in instance) instance['widget'] = widget;
|
||||||
|
if ('config' in instance) instance['config'] = widget.config;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onTitleChange(value: string): void {
|
||||||
|
this.configChange.emit({ key: 'title', value });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLayoutChange(key: string, value: number): void {
|
||||||
|
this.configChange.emit({ key: `layout.${key}`, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-config-panel/index.ts
Normal file
1
src/components/db-config-panel/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbConfigPanelComponent } from './db-config-panel.component';
|
||||||
26
src/components/db-dashboard/db-dashboard.component.html
Normal file
26
src/components/db-dashboard/db-dashboard.component.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!-- toolbar disabled for debugging -->
|
||||||
|
|
||||||
|
<div class="db-dashboard__body">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="db-dashboard__loading">
|
||||||
|
<div class="db-dashboard__spinner"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<db-grid
|
||||||
|
[widgets]="widgets()"
|
||||||
|
[columns]="gridColumns()"
|
||||||
|
[rowHeight]="gridRowHeight()"
|
||||||
|
[gap]="gridGap()"
|
||||||
|
[editMode]="currentEditMode()"
|
||||||
|
[contentDefs]="contentDefs()"
|
||||||
|
[footerDefs]="footerDefs()"
|
||||||
|
(widgetRemove)="onWidgetRemove($event)"
|
||||||
|
(widgetFullscreen)="onWidgetFullscreen($event)"
|
||||||
|
(widgetConfigOpen)="onWidgetConfigOpen($event)"
|
||||||
|
(widgetResizeEnd)="onWidgetResizeEnd($event)"
|
||||||
|
(widgetMoveEnd)="onWidgetMoveEnd($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- config panel and widget picker disabled for debugging -->
|
||||||
|
</div>
|
||||||
40
src/components/db-dashboard/db-dashboard.component.scss
Normal file
40
src/components/db-dashboard/db-dashboard.component.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-container;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--db-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-dashboard__body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-dashboard__loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 20;
|
||||||
|
@include db-glass;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-dashboard__spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--color-border-primary, #e5e7eb);
|
||||||
|
border-top-color: var(--color-primary-500, #3b82f6);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: db-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes db-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
266
src/components/db-dashboard/db-dashboard.component.ts
Normal file
266
src/components/db-dashboard/db-dashboard.component.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, input, output, computed, inject,
|
||||||
|
contentChildren, effect, signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
// import { OverlayStackService } from '@sda/base-ui'; // Temporarily disabled
|
||||||
|
import type {
|
||||||
|
DbDashboard, DbWidget, DbWidgetType, DbEditMode,
|
||||||
|
DbLayoutPreset,
|
||||||
|
} from '../../types/dashboard.types';
|
||||||
|
import type {
|
||||||
|
DbWidgetMoveEvent, DbWidgetResizeEvent, DbWidgetAddEvent,
|
||||||
|
DbWidgetRemoveEvent, DbWidgetConfigChangeEvent, DbWidgetFullscreenEvent,
|
||||||
|
DbEditModeChangeEvent, DbPresetApplyEvent, DbSaveEvent,
|
||||||
|
DbLoadEvent, DbBreakpointChangeEvent, DbDragStartEvent, DbDragEndEvent,
|
||||||
|
} from '../../types/event.types';
|
||||||
|
import { DASHBOARD_CONFIG } from '../../providers/dashboard-config.provider';
|
||||||
|
import { DashboardLayoutService } from '../../services/dashboard-layout.service';
|
||||||
|
import { DashboardDragService } from '../../services/dashboard-drag.service';
|
||||||
|
import { DashboardPersistService } from '../../services/dashboard-persist.service';
|
||||||
|
import { DashboardRegistryService } from '../../services/dashboard-registry.service';
|
||||||
|
import { DbGridComponent } from '../db-grid/db-grid.component';
|
||||||
|
import { DbToolbarComponent } from '../db-toolbar/db-toolbar.component';
|
||||||
|
import { DbWidgetPickerComponent } from '../db-widget-picker/db-widget-picker.component';
|
||||||
|
import { DbConfigPanelComponent } from '../db-config-panel/db-config-panel.component';
|
||||||
|
import { DbWidgetContentDefDirective } from '../../directives/db-widget-content-def.directive';
|
||||||
|
import { DbWidgetFooterDefDirective } from '../../directives/db-widget-footer-def.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, DbGridComponent, DbToolbarComponent,
|
||||||
|
DbWidgetPickerComponent, DbConfigPanelComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './db-dashboard.component.html',
|
||||||
|
styleUrl: './db-dashboard.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: [DashboardLayoutService, DashboardDragService, DashboardPersistService, DashboardRegistryService],
|
||||||
|
host: {
|
||||||
|
'class': 'db-dashboard',
|
||||||
|
'[class.db-dashboard--editing]': 'layoutService.isEditing()',
|
||||||
|
'[class.db-dashboard--loading]': 'loading()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbDashboardComponent {
|
||||||
|
private config = inject(DASHBOARD_CONFIG);
|
||||||
|
protected layoutService = inject(DashboardLayoutService);
|
||||||
|
private dragService = inject(DashboardDragService);
|
||||||
|
private persistService = inject(DashboardPersistService);
|
||||||
|
private registryService = inject(DashboardRegistryService);
|
||||||
|
// private overlayStack = inject(OverlayStackService); // Temporarily disabled
|
||||||
|
|
||||||
|
// Content children
|
||||||
|
protected contentDefs = contentChildren(DbWidgetContentDefDirective);
|
||||||
|
protected footerDefs = contentChildren(DbWidgetFooterDefDirective);
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
readonly dashboard = input.required<DbDashboard>();
|
||||||
|
readonly widgetTypes = input<DbWidgetType[]>([]);
|
||||||
|
readonly editMode = input<DbEditMode | undefined>(undefined);
|
||||||
|
readonly showToolbar = input(this.config.showToolbar);
|
||||||
|
readonly loading = input(false);
|
||||||
|
readonly title = input('');
|
||||||
|
|
||||||
|
// Outputs
|
||||||
|
readonly widgetMove = output<DbWidgetMoveEvent>();
|
||||||
|
readonly widgetResize = output<DbWidgetResizeEvent>();
|
||||||
|
readonly widgetAdd = output<DbWidgetAddEvent>();
|
||||||
|
readonly widgetRemove = output<DbWidgetRemoveEvent>();
|
||||||
|
readonly widgetConfigChange = output<DbWidgetConfigChangeEvent>();
|
||||||
|
readonly widgetFullscreen = output<DbWidgetFullscreenEvent>();
|
||||||
|
readonly editModeChange = output<DbEditModeChangeEvent>();
|
||||||
|
readonly presetApply = output<DbPresetApplyEvent>();
|
||||||
|
readonly dashboardSave = output<DbSaveEvent>();
|
||||||
|
readonly dashboardLoad = output<DbLoadEvent>();
|
||||||
|
readonly breakpointChange = output<DbBreakpointChangeEvent>();
|
||||||
|
readonly dragStart = output<DbDragStartEvent>();
|
||||||
|
readonly dragEnd = output<DbDragEndEvent>();
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
protected widgetPickerOpen = signal(false);
|
||||||
|
protected currentPreset = signal<DbLayoutPreset>(this.config.defaultPreset);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
protected readonly widgets = computed(() => this.layoutService.widgets());
|
||||||
|
protected readonly gridColumns = computed(() => this.layoutService.gridColumns());
|
||||||
|
protected readonly gridRowHeight = computed(() => this.layoutService.gridRowHeight());
|
||||||
|
protected readonly gridGap = computed(() => this.layoutService.gridGap());
|
||||||
|
protected readonly currentEditMode = computed(() => this.layoutService.editMode());
|
||||||
|
protected readonly configPanelWidget = computed(() => {
|
||||||
|
const id = this.layoutService.configPanelWidgetId();
|
||||||
|
return id ? this.layoutService.getWidgetById(id) ?? null : null;
|
||||||
|
});
|
||||||
|
protected readonly isConfigPanelOpen = computed(() => this.layoutService.configPanelWidgetId() !== null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Sync dashboard input → layout service
|
||||||
|
effect(() => {
|
||||||
|
const dashboard = this.dashboard();
|
||||||
|
this.layoutService.setDashboard(dashboard);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register widget types from input
|
||||||
|
effect(() => {
|
||||||
|
const types = this.widgetTypes();
|
||||||
|
if (types.length > 0) {
|
||||||
|
this.registryService.registerTypes(types);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync external editMode input
|
||||||
|
effect(() => {
|
||||||
|
const mode = this.editMode();
|
||||||
|
if (mode !== undefined) {
|
||||||
|
this.layoutService.setEditMode(mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-load from persistence
|
||||||
|
effect(() => {
|
||||||
|
const dashboard = this.dashboard();
|
||||||
|
if (this.config.enablePersistence && this.persistService.hasSaved(dashboard.id)) {
|
||||||
|
const saved = this.persistService.load(dashboard.id);
|
||||||
|
if (saved) {
|
||||||
|
this.layoutService.setDashboard(saved);
|
||||||
|
this.dashboardLoad.emit({ dashboard: saved });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for drag state changes
|
||||||
|
effect(() => {
|
||||||
|
const isDragging = this.dragService.isDragging();
|
||||||
|
const widgetId = this.dragService.draggedWidgetId();
|
||||||
|
if (isDragging && widgetId) {
|
||||||
|
this.dragStart.emit({
|
||||||
|
widgetId,
|
||||||
|
col: this.dragService.dragStartCol(),
|
||||||
|
row: this.dragService.dragStartRow(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
protected onEditModeToggle(): void {
|
||||||
|
this.layoutService.toggleEditMode();
|
||||||
|
this.editModeChange.emit({ mode: this.layoutService.editMode() });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAddWidgetClick(): void {
|
||||||
|
this.widgetPickerOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetTypeSelect(widgetType: DbWidgetType): void {
|
||||||
|
const widget = this.layoutService.addWidget(
|
||||||
|
widgetType.type,
|
||||||
|
widgetType.label,
|
||||||
|
widgetType.defaultLayout,
|
||||||
|
);
|
||||||
|
widget.icon = widgetType.icon;
|
||||||
|
this.widgetAdd.emit({ widget, widgetType });
|
||||||
|
this.widgetPickerOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPresetSelect(preset: DbLayoutPreset): void {
|
||||||
|
const previousPreset = this.currentPreset();
|
||||||
|
this.layoutService.applyPreset(preset);
|
||||||
|
this.currentPreset.set(preset);
|
||||||
|
this.presetApply.emit({ preset, previousPreset });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSave(): void {
|
||||||
|
const dashboard = this.layoutService.toDashboard(
|
||||||
|
this.dashboard().id,
|
||||||
|
this.dashboard().title,
|
||||||
|
);
|
||||||
|
if (this.config.enablePersistence) {
|
||||||
|
this.persistService.save(dashboard.id, dashboard);
|
||||||
|
}
|
||||||
|
this.dashboardSave.emit({ dashboard });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetRemove(widget: DbWidget): void {
|
||||||
|
this.layoutService.removeWidget(widget.id);
|
||||||
|
this.widgetRemove.emit({ widget });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetFullscreen(widget: DbWidget): void {
|
||||||
|
const currentFullscreen = this.layoutService.fullscreenWidgetId();
|
||||||
|
const isFullscreen = currentFullscreen !== widget.id;
|
||||||
|
this.layoutService.setFullscreen(isFullscreen ? widget.id : null);
|
||||||
|
this.widgetFullscreen.emit({ widget, fullscreen: isFullscreen });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetConfigOpen(widget: DbWidget): void {
|
||||||
|
this.layoutService.toggleConfigPanel(widget.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetMoveEnd(event: { widget: DbWidget; col: number; row: number }): void {
|
||||||
|
const fromCol = event.widget.layout.col;
|
||||||
|
const fromRow = event.widget.layout.row;
|
||||||
|
this.layoutService.moveWidget(event.widget.id, event.col, event.row);
|
||||||
|
this.widgetMove.emit({
|
||||||
|
widget: event.widget,
|
||||||
|
fromCol,
|
||||||
|
fromRow,
|
||||||
|
toCol: event.col,
|
||||||
|
toRow: event.row,
|
||||||
|
});
|
||||||
|
this.dragEnd.emit({ widgetId: event.widget.id, dropped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetResizeEnd(event: { widget: DbWidget; colSpan: number; rowSpan: number }): void {
|
||||||
|
const fromColSpan = event.widget.layout.colSpan;
|
||||||
|
const fromRowSpan = event.widget.layout.rowSpan;
|
||||||
|
this.layoutService.resizeWidget(event.widget.id, event.colSpan, event.rowSpan);
|
||||||
|
this.widgetResize.emit({
|
||||||
|
widget: event.widget,
|
||||||
|
fromColSpan,
|
||||||
|
fromRowSpan,
|
||||||
|
toColSpan: event.colSpan,
|
||||||
|
toRowSpan: event.rowSpan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onConfigChange(change: { key: string; value: unknown }): void {
|
||||||
|
const widget = this.configPanelWidget();
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
if (change.key === 'title') {
|
||||||
|
this.layoutService.widgets.update(widgets =>
|
||||||
|
widgets.map(w => w.id === widget.id ? { ...w, title: change.value as string } : w),
|
||||||
|
);
|
||||||
|
} else if (change.key.startsWith('layout.')) {
|
||||||
|
const layoutKey = change.key.replace('layout.', '');
|
||||||
|
if (layoutKey === 'colSpan' || layoutKey === 'rowSpan') {
|
||||||
|
this.layoutService.resizeWidget(
|
||||||
|
widget.id,
|
||||||
|
layoutKey === 'colSpan' ? (change.value as number) : widget.layout.colSpan,
|
||||||
|
layoutKey === 'rowSpan' ? (change.value as number) : widget.layout.rowSpan,
|
||||||
|
);
|
||||||
|
} else if (layoutKey === 'col' || layoutKey === 'row') {
|
||||||
|
this.layoutService.moveWidget(
|
||||||
|
widget.id,
|
||||||
|
layoutKey === 'col' ? (change.value as number) : widget.layout.col,
|
||||||
|
layoutKey === 'row' ? (change.value as number) : widget.layout.row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const previousValue = widget.config?.[change.key];
|
||||||
|
this.layoutService.updateWidgetConfig(widget.id, change.key, change.value);
|
||||||
|
this.widgetConfigChange.emit({
|
||||||
|
widget,
|
||||||
|
key: change.key,
|
||||||
|
value: change.value,
|
||||||
|
previousValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onConfigPanelClose(): void {
|
||||||
|
this.layoutService.closeConfigPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-dashboard/index.ts
Normal file
1
src/components/db-dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbDashboardComponent } from './db-dashboard.component';
|
||||||
44
src/components/db-grid/db-grid.component.html
Normal file
44
src/components/db-grid/db-grid.component.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<div class="db-grid__container" #gridRef>
|
||||||
|
@for (widget of widgets(); track widget.id) {
|
||||||
|
<div
|
||||||
|
class="db-grid__cell"
|
||||||
|
[style.grid-column]="widget.layout.col + ' / span ' + widget.layout.colSpan"
|
||||||
|
[style.grid-row]="widget.layout.row + ' / span ' + widget.layout.rowSpan"
|
||||||
|
>
|
||||||
|
<db-widget
|
||||||
|
[widget]="widget"
|
||||||
|
[editMode]="editMode()"
|
||||||
|
[fullscreen]="false"
|
||||||
|
[contentTemplate]="getContentTemplate(widget.type)"
|
||||||
|
[footerTemplate]="getFooterTemplate(widget.type)"
|
||||||
|
(remove)="onWidgetRemove(widget)"
|
||||||
|
(fullscreenToggle)="onWidgetFullscreen(widget)"
|
||||||
|
(configOpen)="onWidgetConfigOpen(widget)"
|
||||||
|
(action)="widgetAction.emit({ widget, action: $event })"
|
||||||
|
(resizeEnd)="onWidgetResizeEnd(widget, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resize placeholder for this widget -->
|
||||||
|
@if (getResizePlaceholder(widget); as rp) {
|
||||||
|
<db-placeholder
|
||||||
|
[col]="rp.col"
|
||||||
|
[row]="rp.row"
|
||||||
|
[colSpan]="rp.colSpan"
|
||||||
|
[rowSpan]="rp.rowSpan"
|
||||||
|
type="resize"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Drag move placeholder -->
|
||||||
|
@if (dragPreview(); as dp) {
|
||||||
|
<db-placeholder
|
||||||
|
[col]="dp.col"
|
||||||
|
[row]="dp.row"
|
||||||
|
[colSpan]="dp.colSpan"
|
||||||
|
[rowSpan]="dp.rowSpan"
|
||||||
|
type="move"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
23
src/components/db-grid/db-grid.component.scss
Normal file
23
src/components/db-grid/db-grid.component.scss
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--db-grid-gap);
|
||||||
|
@include db-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grid__container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--db-grid-columns, 12), 1fr);
|
||||||
|
grid-auto-rows: var(--db-grid-row-height, 80px);
|
||||||
|
gap: var(--db-grid-gap, 16px);
|
||||||
|
min-height: var(--db-grid-min-height, 400px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grid__cell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
139
src/components/db-grid/db-grid.component.ts
Normal file
139
src/components/db-grid/db-grid.component.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, input, output, inject,
|
||||||
|
computed, ElementRef, viewChild, TemplateRef,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import type { DbWidget, DbEditMode } from '../../types/dashboard.types';
|
||||||
|
import { DashboardDragService } from '../../services/dashboard-drag.service';
|
||||||
|
import { DashboardLayoutService } from '../../services/dashboard-layout.service';
|
||||||
|
import { DbWidgetComponent } from '../db-widget/db-widget.component';
|
||||||
|
import { DbPlaceholderComponent } from '../db-placeholder/db-placeholder.component';
|
||||||
|
import type { DbWidgetContentDefDirective } from '../../directives/db-widget-content-def.directive';
|
||||||
|
import type { DbWidgetFooterDefDirective } from '../../directives/db-widget-footer-def.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-grid',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, DbWidgetComponent, DbPlaceholderComponent],
|
||||||
|
templateUrl: './db-grid.component.html',
|
||||||
|
styleUrl: './db-grid.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-grid',
|
||||||
|
'[style.--db-grid-columns]': 'columns()',
|
||||||
|
'[style.--db-grid-row-height]': 'rowHeightPx()',
|
||||||
|
'[style.--db-grid-gap]': 'gapPx()',
|
||||||
|
'(dragover)': 'onDragOver($event)',
|
||||||
|
'(drop)': 'onDrop($event)',
|
||||||
|
'(dragleave)': 'onDragLeave($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbGridComponent {
|
||||||
|
private dragService = inject(DashboardDragService);
|
||||||
|
private layoutService = inject(DashboardLayoutService);
|
||||||
|
private gridEl = viewChild<ElementRef<HTMLElement>>('gridRef');
|
||||||
|
|
||||||
|
readonly widgets = input.required<DbWidget[]>();
|
||||||
|
readonly columns = input(12);
|
||||||
|
readonly rowHeight = input(80);
|
||||||
|
readonly gap = input(16);
|
||||||
|
readonly editMode = input<DbEditMode>('view');
|
||||||
|
readonly contentDefs = input<readonly DbWidgetContentDefDirective[]>([]);
|
||||||
|
readonly footerDefs = input<readonly DbWidgetFooterDefDirective[]>([]);
|
||||||
|
|
||||||
|
readonly widgetRemove = output<DbWidget>();
|
||||||
|
readonly widgetFullscreen = output<DbWidget>();
|
||||||
|
readonly widgetConfigOpen = output<DbWidget>();
|
||||||
|
readonly widgetAction = output<{ widget: DbWidget; action: string }>();
|
||||||
|
readonly widgetResizeEnd = output<{ widget: DbWidget; colSpan: number; rowSpan: number }>();
|
||||||
|
readonly widgetMoveEnd = output<{ widget: DbWidget; col: number; row: number }>();
|
||||||
|
|
||||||
|
protected readonly rowHeightPx = computed(() => `${this.rowHeight()}px`);
|
||||||
|
protected readonly gapPx = computed(() => `${this.gap()}px`);
|
||||||
|
|
||||||
|
protected readonly dragPreview = computed(() => this.dragService.dragPreview());
|
||||||
|
protected readonly resizePreview = computed(() => this.dragService.resizePreview());
|
||||||
|
protected readonly resizeWidgetId = computed(() => this.dragService.resizeWidgetId());
|
||||||
|
protected readonly isDragging = computed(() => this.dragService.isDragging());
|
||||||
|
|
||||||
|
protected getContentTemplate(widgetType: string): TemplateRef<unknown> | null {
|
||||||
|
const defs = this.contentDefs();
|
||||||
|
const match = defs.find(d => d.dbWidgetContentDef() === widgetType);
|
||||||
|
return match?.templateRef ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFooterTemplate(widgetType: string): TemplateRef<unknown> | null {
|
||||||
|
const defs = this.footerDefs();
|
||||||
|
const match = defs.find(d => d.dbWidgetFooterDef() === widgetType);
|
||||||
|
return match?.templateRef ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getResizePlaceholder(widget: DbWidget) {
|
||||||
|
const preview = this.resizePreview();
|
||||||
|
if (!preview || this.resizeWidgetId() !== widget.id) return null;
|
||||||
|
return {
|
||||||
|
col: widget.layout.col,
|
||||||
|
row: widget.layout.row,
|
||||||
|
colSpan: preview.colSpan,
|
||||||
|
rowSpan: preview.rowSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDragOver(event: DragEvent): void {
|
||||||
|
if (!this.dragService.isDragging()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer!.dropEffect = 'move';
|
||||||
|
|
||||||
|
const el = this.getGridElement();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const pos = this.dragService.calculateGridPosition(
|
||||||
|
event, el, this.columns(), this.rowHeight(), this.gap(),
|
||||||
|
);
|
||||||
|
this.dragService.updateDragPreview(pos.col, pos.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDrop(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const result = this.dragService.endDrag();
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const widget = this.widgets().find(w => w.id === result.widgetId);
|
||||||
|
if (widget) {
|
||||||
|
this.widgetMoveEnd.emit({ widget, col: result.col, row: result.row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDragLeave(event: DragEvent): void {
|
||||||
|
// Only handle if leaving the grid entirely
|
||||||
|
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||||
|
const el = this.getGridElement();
|
||||||
|
if (el && relatedTarget && !el.contains(relatedTarget)) {
|
||||||
|
this.dragService.cancelAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetRemove(widget: DbWidget): void {
|
||||||
|
this.widgetRemove.emit(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetFullscreen(widget: DbWidget): void {
|
||||||
|
this.widgetFullscreen.emit(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetConfigOpen(widget: DbWidget): void {
|
||||||
|
this.widgetConfigOpen.emit(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onWidgetResizeEnd(widget: DbWidget, result: { colSpan: number; rowSpan: number }): void {
|
||||||
|
this.widgetResizeEnd.emit({ widget, ...result });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected trackByWidgetId(_index: number, widget: DbWidget): string {
|
||||||
|
return widget.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGridElement(): HTMLElement | null {
|
||||||
|
return this.gridEl()?.nativeElement ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-grid/index.ts
Normal file
1
src/components/db-grid/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbGridComponent } from './db-grid.component';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="db-layout-preset-picker__grid">
|
||||||
|
@for (option of presets; track option.preset) {
|
||||||
|
<button
|
||||||
|
class="db-layout-preset-picker__thumbnail"
|
||||||
|
[class.db-layout-preset-picker__thumbnail--active]="selected() === option.preset"
|
||||||
|
[uiTooltip]="option.label"
|
||||||
|
(click)="presetSelect.emit(option.preset)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 40 30"
|
||||||
|
class="db-layout-preset-picker__svg"
|
||||||
|
[innerHTML]="option.svg"
|
||||||
|
></svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-layout-preset-picker__grid {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-layout-preset-picker__thumbnail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--db-preset-thumbnail-size);
|
||||||
|
height: calc(var(--db-preset-thumbnail-size) * 0.75);
|
||||||
|
padding: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--db-preset-thumbnail-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--db-transition),
|
||||||
|
background var(--db-transition);
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-border-hover, #d1d5db);
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border-color: var(--db-preset-thumbnail-active-border);
|
||||||
|
color: var(--color-primary-500, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-layout-preset-picker__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { TooltipDirective } from '@sda/base-ui';
|
||||||
|
import type { DbLayoutPreset } from '../../types/dashboard.types';
|
||||||
|
|
||||||
|
interface PresetOption {
|
||||||
|
preset: DbLayoutPreset;
|
||||||
|
label: string;
|
||||||
|
svg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-layout-preset-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TooltipDirective],
|
||||||
|
templateUrl: './db-layout-preset-picker.component.html',
|
||||||
|
styleUrl: './db-layout-preset-picker.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { 'class': 'db-layout-preset-picker' },
|
||||||
|
})
|
||||||
|
export class DbLayoutPresetPickerComponent {
|
||||||
|
readonly selected = input<DbLayoutPreset>('custom');
|
||||||
|
readonly presetSelect = output<DbLayoutPreset>();
|
||||||
|
|
||||||
|
protected readonly presets: PresetOption[] = [
|
||||||
|
{
|
||||||
|
preset: 'single',
|
||||||
|
label: 'Single',
|
||||||
|
svg: `<rect x="2" y="2" width="36" height="26" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: 'two-column',
|
||||||
|
label: 'Two Column',
|
||||||
|
svg: `<rect x="2" y="2" width="16" height="26" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="22" y="2" width="16" height="26" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: 'sidebar-main',
|
||||||
|
label: 'Sidebar + Main',
|
||||||
|
svg: `<rect x="2" y="2" width="10" height="26" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="16" y="2" width="22" height="26" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: 'four-grid',
|
||||||
|
label: 'Four Grid',
|
||||||
|
svg: `<rect x="2" y="2" width="16" height="11" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="22" y="2" width="16" height="11" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="2" y="17" width="16" height="11" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="22" y="17" width="16" height="11" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
svg: `<rect x="2" y="2" width="10" height="8" rx="1" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="15" y="2" width="10" height="8" rx="1" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="28" y="2" width="10" height="8" rx="1" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="2" y="14" width="22" height="14" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/><rect x="28" y="14" width="10" height="14" rx="2" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1"/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preset: 'custom',
|
||||||
|
label: 'Custom',
|
||||||
|
svg: `<rect x="2" y="2" width="36" height="26" rx="2" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="3 2"/><line x1="14" y1="2" x2="14" y2="28" stroke="currentColor" stroke-width="0.5" opacity="0.3"/><line x1="26" y1="2" x2="26" y2="28" stroke="currentColor" stroke-width="0.5" opacity="0.3"/><line x1="2" y1="15" x2="38" y2="15" stroke="currentColor" stroke-width="0.5" opacity="0.3"/>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
1
src/components/db-layout-preset-picker/index.ts
Normal file
1
src/components/db-layout-preset-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbLayoutPresetPickerComponent } from './db-layout-preset-picker.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div class="db-placeholder__inner"></div>
|
||||||
18
src/components/db-placeholder/db-placeholder.component.scss
Normal file
18
src/components/db-placeholder/db-placeholder.component.scss
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-placeholder__inner {
|
||||||
|
@include db-placeholder;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: all var(--db-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.db-placeholder--resize) .db-placeholder__inner {
|
||||||
|
border-color: var(--color-success-500, #22c55e);
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
}
|
||||||
25
src/components/db-placeholder/db-placeholder.component.ts
Normal file
25
src/components/db-placeholder/db-placeholder.component.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-placeholder',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './db-placeholder.component.html',
|
||||||
|
styleUrl: './db-placeholder.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-placeholder',
|
||||||
|
'[style.grid-column]': 'gridColumn()',
|
||||||
|
'[style.grid-row]': 'gridRow()',
|
||||||
|
'[class.db-placeholder--resize]': 'type() === "resize"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbPlaceholderComponent {
|
||||||
|
readonly col = input.required<number>();
|
||||||
|
readonly row = input.required<number>();
|
||||||
|
readonly colSpan = input.required<number>();
|
||||||
|
readonly rowSpan = input.required<number>();
|
||||||
|
readonly type = input<'move' | 'resize'>('move');
|
||||||
|
|
||||||
|
readonly gridColumn = computed(() => `${this.col()} / span ${this.colSpan()}`);
|
||||||
|
readonly gridRow = computed(() => `${this.row()} / span ${this.rowSpan()}`);
|
||||||
|
}
|
||||||
1
src/components/db-placeholder/index.ts
Normal file
1
src/components/db-placeholder/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbPlaceholderComponent } from './db-placeholder.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div class="db-resize-handle__indicator"></div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-resize-handle-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SE corner handle (8x8 dot)
|
||||||
|
:host(.db-resize-handle--se) {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
|
||||||
|
.db-resize-handle__indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: var(--db-resize-handle-size);
|
||||||
|
height: var(--db-resize-handle-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--db-resize-handle-color);
|
||||||
|
border: 2px solid var(--db-resize-handle-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E edge handle (vertical bar)
|
||||||
|
:host(.db-resize-handle--e) {
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 32px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
|
||||||
|
.db-resize-handle__indicator {
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--db-resize-handle-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S edge handle (horizontal bar)
|
||||||
|
:host(.db-resize-handle--s) {
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
|
|
||||||
|
.db-resize-handle__indicator {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
margin: auto 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--db-resize-handle-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/components/db-resize-handle/db-resize-handle.component.ts
Normal file
123
src/components/db-resize-handle/db-resize-handle.component.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, input, inject, NgZone, OnDestroy,
|
||||||
|
ElementRef, output,
|
||||||
|
} from '@angular/core';
|
||||||
|
import type { DbResizeDirection } from '../../types/dashboard.types';
|
||||||
|
import { DashboardDragService } from '../../services/dashboard-drag.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-resize-handle',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './db-resize-handle.component.html',
|
||||||
|
styleUrl: './db-resize-handle.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-resize-handle',
|
||||||
|
'[class.db-resize-handle--se]': 'direction() === "se"',
|
||||||
|
'[class.db-resize-handle--e]': 'direction() === "e"',
|
||||||
|
'[class.db-resize-handle--s]': 'direction() === "s"',
|
||||||
|
'(mousedown)': 'onMouseDown($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbResizeHandleComponent implements OnDestroy {
|
||||||
|
private zone = inject(NgZone);
|
||||||
|
private dragService = inject(DashboardDragService);
|
||||||
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
readonly direction = input.required<DbResizeDirection>();
|
||||||
|
readonly widgetId = input.required<string>();
|
||||||
|
readonly colSpan = input.required<number>();
|
||||||
|
readonly rowSpan = input.required<number>();
|
||||||
|
readonly minColSpan = input(1);
|
||||||
|
readonly minRowSpan = input(1);
|
||||||
|
readonly maxColSpan = input(12);
|
||||||
|
readonly maxRowSpan = input(20);
|
||||||
|
|
||||||
|
readonly resizeStart = output<void>();
|
||||||
|
readonly resizeEnd = output<{ colSpan: number; rowSpan: number }>();
|
||||||
|
|
||||||
|
private startX = 0;
|
||||||
|
private startY = 0;
|
||||||
|
private moveHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
private upHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
onMouseDown(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
this.startX = event.clientX;
|
||||||
|
this.startY = event.clientY;
|
||||||
|
|
||||||
|
this.dragService.startResize(
|
||||||
|
this.widgetId(),
|
||||||
|
this.direction(),
|
||||||
|
this.colSpan(),
|
||||||
|
this.rowSpan(),
|
||||||
|
);
|
||||||
|
this.resizeStart.emit();
|
||||||
|
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.moveHandler = (e: MouseEvent) => this.onMouseMove(e);
|
||||||
|
this.upHandler = (e: MouseEvent) => this.onMouseUp(e);
|
||||||
|
document.addEventListener('mousemove', this.moveHandler);
|
||||||
|
document.addEventListener('mouseup', this.upHandler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseMove(event: MouseEvent): void {
|
||||||
|
const dir = this.direction();
|
||||||
|
const gridEl = this.el.nativeElement.closest('.db-grid') as HTMLElement | null;
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
const gridRect = gridEl.getBoundingClientRect();
|
||||||
|
const columns = parseInt(getComputedStyle(gridEl).getPropertyValue('--db-grid-columns') || '12');
|
||||||
|
const cellWidth = (gridRect.width - 16 * (columns - 1)) / columns;
|
||||||
|
const rowHeight = parseInt(getComputedStyle(gridEl).getPropertyValue('--db-grid-row-height') || '80');
|
||||||
|
|
||||||
|
const deltaX = event.clientX - this.startX;
|
||||||
|
const deltaY = event.clientY - this.startY;
|
||||||
|
|
||||||
|
let newColSpan = this.colSpan();
|
||||||
|
let newRowSpan = this.rowSpan();
|
||||||
|
|
||||||
|
if (dir === 'se' || dir === 'e') {
|
||||||
|
const deltaSpanX = Math.round(deltaX / (cellWidth + 16));
|
||||||
|
newColSpan = Math.min(Math.max(this.colSpan() + deltaSpanX, this.minColSpan()), this.maxColSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir === 'se' || dir === 's') {
|
||||||
|
const deltaSpanY = Math.round(deltaY / (rowHeight + 16));
|
||||||
|
newRowSpan = Math.min(Math.max(this.rowSpan() + deltaSpanY, this.minRowSpan()), this.maxRowSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.dragService.updateResizePreview(newColSpan, newRowSpan);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseUp(_event: MouseEvent): void {
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
this.zone.run(() => {
|
||||||
|
const result = this.dragService.endResize();
|
||||||
|
if (result) {
|
||||||
|
this.resizeEnd.emit({ colSpan: result.colSpan, rowSpan: result.rowSpan });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.moveHandler) {
|
||||||
|
document.removeEventListener('mousemove', this.moveHandler);
|
||||||
|
this.moveHandler = null;
|
||||||
|
}
|
||||||
|
if (this.upHandler) {
|
||||||
|
document.removeEventListener('mouseup', this.upHandler);
|
||||||
|
this.upHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-resize-handle/index.ts
Normal file
1
src/components/db-resize-handle/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbResizeHandleComponent } from './db-resize-handle.component';
|
||||||
45
src/components/db-toolbar/db-toolbar.component.html
Normal file
45
src/components/db-toolbar/db-toolbar.component.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="db-toolbar__left">
|
||||||
|
@if (title()) {
|
||||||
|
<span class="db-toolbar__title">{{ title() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-toolbar__center">
|
||||||
|
@if (isEditing && showPresetPicker()) {
|
||||||
|
<db-layout-preset-picker
|
||||||
|
(presetSelect)="presetSelect.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-toolbar__right">
|
||||||
|
<ui-button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
(click)="editModeToggle.emit()"
|
||||||
|
uiTooltip="Toggle edit mode"
|
||||||
|
>
|
||||||
|
<ui-icon [name]="isEditing ? 'lock-open' : 'lock'" [size]="14" />
|
||||||
|
</ui-button>
|
||||||
|
|
||||||
|
@if (isEditing) {
|
||||||
|
<ui-button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
(click)="addWidget.emit()"
|
||||||
|
uiTooltip="Add widget"
|
||||||
|
>
|
||||||
|
<ui-icon name="plus" [size]="14" />
|
||||||
|
<span>Add Widget</span>
|
||||||
|
</ui-button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ui-button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
(click)="save.emit()"
|
||||||
|
uiTooltip="Save dashboard"
|
||||||
|
>
|
||||||
|
<ui-icon name="save" [size]="14" />
|
||||||
|
</ui-button>
|
||||||
|
</div>
|
||||||
34
src/components/db-toolbar/db-toolbar.component.scss
Normal file
34
src/components/db-toolbar/db-toolbar.component.scss
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-flex-between;
|
||||||
|
background: var(--db-toolbar-bg);
|
||||||
|
border-bottom: 1px solid var(--db-toolbar-border);
|
||||||
|
padding: var(--db-toolbar-padding);
|
||||||
|
gap: var(--db-toolbar-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-toolbar__left {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-toolbar__title {
|
||||||
|
@include db-truncate;
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-toolbar__center {
|
||||||
|
@include db-flex-center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-toolbar__right {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
32
src/components/db-toolbar/db-toolbar.component.ts
Normal file
32
src/components/db-toolbar/db-toolbar.component.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IconComponent, ButtonComponent, TooltipDirective } from '@sda/base-ui';
|
||||||
|
import type { DbEditMode, DbLayoutPreset } from '../../types/dashboard.types';
|
||||||
|
import { DbLayoutPresetPickerComponent } from '../db-layout-preset-picker/db-layout-preset-picker.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-toolbar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, IconComponent, ButtonComponent,
|
||||||
|
TooltipDirective, DbLayoutPresetPickerComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './db-toolbar.component.html',
|
||||||
|
styleUrl: './db-toolbar.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { 'class': 'db-toolbar' },
|
||||||
|
})
|
||||||
|
export class DbToolbarComponent {
|
||||||
|
readonly editMode = input<DbEditMode>('view');
|
||||||
|
readonly title = input<string>('');
|
||||||
|
readonly showPresetPicker = input(true);
|
||||||
|
|
||||||
|
readonly editModeToggle = output<void>();
|
||||||
|
readonly addWidget = output<void>();
|
||||||
|
readonly presetSelect = output<DbLayoutPreset>();
|
||||||
|
readonly save = output<void>();
|
||||||
|
|
||||||
|
protected get isEditing(): boolean {
|
||||||
|
return this.editMode() === 'edit';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-toolbar/index.ts
Normal file
1
src/components/db-toolbar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbToolbarComponent } from './db-toolbar.component';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<div
|
||||||
|
class="db-widget-header__left"
|
||||||
|
[attr.draggable]="editMode() === 'edit' && !widget().locked ? 'true' : null"
|
||||||
|
(dragstart)="onDragStart($event)"
|
||||||
|
>
|
||||||
|
@if (editMode() === 'edit' && !widget().locked) {
|
||||||
|
<ui-icon name="grip-vertical" [size]="14" class="db-widget-header__drag-handle" uiTooltip="Drag to move" />
|
||||||
|
}
|
||||||
|
@if (widget().icon) {
|
||||||
|
<ui-icon [name]="widget().icon!" [size]="16" class="db-widget-header__icon" />
|
||||||
|
}
|
||||||
|
<span class="db-widget-header__title">{{ widget().title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-widget-header__actions">
|
||||||
|
<ui-button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
(click)="fullscreenToggle.emit()"
|
||||||
|
[uiTooltip]="fullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||||
|
>
|
||||||
|
<ui-icon [name]="fullscreen() ? 'minimize-2' : 'maximize-2'" [size]="14" />
|
||||||
|
</ui-button>
|
||||||
|
|
||||||
|
<ui-button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
(click)="configOpen.emit()"
|
||||||
|
uiTooltip="Widget settings"
|
||||||
|
>
|
||||||
|
<ui-icon name="settings" [size]="14" />
|
||||||
|
</ui-button>
|
||||||
|
|
||||||
|
@if (editMode() === 'edit') {
|
||||||
|
<ui-dropdown-menu [items]="menuItems()">
|
||||||
|
<ui-button variant="ghost" size="sm" trigger>
|
||||||
|
<ui-icon name="more-vertical" [size]="14" />
|
||||||
|
</ui-button>
|
||||||
|
</ui-dropdown-menu>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-flex-between;
|
||||||
|
background: var(--db-widget-header-bg);
|
||||||
|
padding: var(--db-widget-header-padding);
|
||||||
|
border-bottom: var(--db-widget-header-border);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__left {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity var(--db-transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__title {
|
||||||
|
@include db-truncate;
|
||||||
|
font-size: var(--db-widget-header-font-size);
|
||||||
|
font-weight: var(--db-widget-header-font-weight);
|
||||||
|
color: var(--db-widget-header-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__actions {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-header__menu-item {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
transition: background var(--db-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-hover, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: var(--color-error-500, #ef4444);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
IconComponent, ButtonComponent, TooltipDirective, DropdownMenuComponent,
|
||||||
|
} from '@sda/base-ui';
|
||||||
|
import type { DbWidget, DbEditMode } from '../../types/dashboard.types';
|
||||||
|
import type { DropdownMenuItem } from '@sda/base-ui';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-widget-header',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IconComponent, ButtonComponent, TooltipDirective, DropdownMenuComponent],
|
||||||
|
templateUrl: './db-widget-header.component.html',
|
||||||
|
styleUrl: './db-widget-header.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-widget-header',
|
||||||
|
'[class.db-widget-header--editing]': 'editMode() === "edit"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbWidgetHeaderComponent {
|
||||||
|
readonly widget = input.required<DbWidget>();
|
||||||
|
readonly editMode = input<DbEditMode>('view');
|
||||||
|
readonly fullscreen = input(false);
|
||||||
|
|
||||||
|
readonly remove = output<void>();
|
||||||
|
readonly fullscreenToggle = output<void>();
|
||||||
|
readonly configOpen = output<void>();
|
||||||
|
readonly action = output<string>();
|
||||||
|
|
||||||
|
protected readonly menuItems = computed<DropdownMenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'remove',
|
||||||
|
label: 'Remove widget',
|
||||||
|
icon: 'trash-2',
|
||||||
|
danger: true,
|
||||||
|
action: () => this.remove.emit(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
protected onDragStart(event: DragEvent): void {
|
||||||
|
if (this.editMode() !== 'edit' || this.widget().locked) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer?.setData('text/plain', this.widget().id);
|
||||||
|
event.dataTransfer!.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-widget-header/index.ts
Normal file
1
src/components/db-widget-header/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbWidgetHeaderComponent } from './db-widget-header.component';
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<ui-modal [open]="open()" size="lg" (close)="close.emit()">
|
||||||
|
<div class="db-widget-picker__content">
|
||||||
|
<div class="db-widget-picker__header">
|
||||||
|
<ui-text variant="h3">Add Widget</ui-text>
|
||||||
|
<ui-input
|
||||||
|
placeholder="Search widgets..."
|
||||||
|
[(ngModel)]="searchQueryValue"
|
||||||
|
class="db-widget-picker__search"
|
||||||
|
>
|
||||||
|
<ui-icon name="search" [size]="14" prefix />
|
||||||
|
</ui-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-widget-picker__body">
|
||||||
|
@for (category of categories(); track category.key) {
|
||||||
|
<div class="db-widget-picker__category">
|
||||||
|
<div class="db-widget-picker__category-header">
|
||||||
|
@if (category.icon) {
|
||||||
|
<ui-icon [name]="category.icon" [size]="14" />
|
||||||
|
}
|
||||||
|
<span class="db-widget-picker__category-label">{{ category.label }}</span>
|
||||||
|
<ui-badge variant="default" size="sm">{{ getCategoryTypes(category.key).length }}</ui-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-widget-picker__grid">
|
||||||
|
@for (widgetType of getCategoryTypes(category.key); track widgetType.type) {
|
||||||
|
<ui-card
|
||||||
|
class="db-widget-picker__card"
|
||||||
|
[hoverable]="true"
|
||||||
|
[clickable]="true"
|
||||||
|
(click)="onSelect(widgetType)"
|
||||||
|
>
|
||||||
|
<div class="db-widget-picker__card-content">
|
||||||
|
@if (widgetType.icon) {
|
||||||
|
<ui-icon [name]="widgetType.icon" [size]="24" class="db-widget-picker__card-icon" />
|
||||||
|
}
|
||||||
|
<span class="db-widget-picker__card-label">{{ widgetType.label }}</span>
|
||||||
|
@if (widgetType.description) {
|
||||||
|
<span class="db-widget-picker__card-desc">{{ widgetType.description }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ui-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Uncategorized widgets -->
|
||||||
|
@if (groupedTypes().has('uncategorized')) {
|
||||||
|
<div class="db-widget-picker__category">
|
||||||
|
<div class="db-widget-picker__category-header">
|
||||||
|
<span class="db-widget-picker__category-label">Other</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-widget-picker__grid">
|
||||||
|
@for (widgetType of getCategoryTypes('uncategorized'); track widgetType.type) {
|
||||||
|
<ui-card
|
||||||
|
class="db-widget-picker__card"
|
||||||
|
[hoverable]="true"
|
||||||
|
[clickable]="true"
|
||||||
|
(click)="onSelect(widgetType)"
|
||||||
|
>
|
||||||
|
<div class="db-widget-picker__card-content">
|
||||||
|
@if (widgetType.icon) {
|
||||||
|
<ui-icon [name]="widgetType.icon" [size]="24" class="db-widget-picker__card-icon" />
|
||||||
|
}
|
||||||
|
<span class="db-widget-picker__card-label">{{ widgetType.label }}</span>
|
||||||
|
@if (widgetType.description) {
|
||||||
|
<span class="db-widget-picker__card-desc">{{ widgetType.description }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ui-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (filteredTypes().length === 0) {
|
||||||
|
<div class="db-widget-picker__empty">
|
||||||
|
<ui-icon name="search" [size]="32" />
|
||||||
|
<ui-text variant="body">No widgets found</ui-text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ui-modal>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--db-widget-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
@include db-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__category {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__category-header {
|
||||||
|
@include db-flex-center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__category-label {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--db-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--db-picker-card-hover-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__card-icon {
|
||||||
|
color: var(--color-primary-500, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__card-label {
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__card-desc {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget-picker__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
ModalComponent, InputComponent, CardComponent, IconComponent,
|
||||||
|
BadgeComponent, TypographyComponent,
|
||||||
|
} from '@sda/base-ui';
|
||||||
|
import type { DbWidgetType } from '../../types/dashboard.types';
|
||||||
|
import { DashboardRegistryService } from '../../services/dashboard-registry.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-widget-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, ModalComponent, InputComponent, CardComponent,
|
||||||
|
IconComponent, BadgeComponent, TypographyComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './db-widget-picker.component.html',
|
||||||
|
styleUrl: './db-widget-picker.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { 'class': 'db-widget-picker' },
|
||||||
|
})
|
||||||
|
export class DbWidgetPickerComponent {
|
||||||
|
private registryService = inject(DashboardRegistryService);
|
||||||
|
|
||||||
|
readonly open = input(false);
|
||||||
|
|
||||||
|
readonly widgetSelect = output<DbWidgetType>();
|
||||||
|
readonly close = output<void>();
|
||||||
|
|
||||||
|
protected searchQuery = signal('');
|
||||||
|
|
||||||
|
// For ngModel binding
|
||||||
|
protected get searchQueryValue(): string {
|
||||||
|
return this.searchQuery();
|
||||||
|
}
|
||||||
|
protected set searchQueryValue(value: string) {
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly filteredTypes = computed(() => {
|
||||||
|
const query = this.searchQuery();
|
||||||
|
return query
|
||||||
|
? this.registryService.searchTypes(query)
|
||||||
|
: this.registryService.widgetTypes();
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly groupedTypes = computed(() => {
|
||||||
|
const types = this.filteredTypes();
|
||||||
|
const map = new Map<string, DbWidgetType[]>();
|
||||||
|
for (const t of types) {
|
||||||
|
const key = t.category ?? 'uncategorized';
|
||||||
|
const group = map.get(key) ?? [];
|
||||||
|
group.push(t);
|
||||||
|
map.set(key, group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly categories = computed(() => {
|
||||||
|
const cats = this.registryService.categories();
|
||||||
|
const grouped = this.groupedTypes();
|
||||||
|
return cats.filter(c => grouped.has(c.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onSelect(widgetType: DbWidgetType): void {
|
||||||
|
this.widgetSelect.emit(widgetType);
|
||||||
|
this.close.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getCategoryTypes(key: string): DbWidgetType[] {
|
||||||
|
return this.groupedTypes().get(key) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-widget-picker/index.ts
Normal file
1
src/components/db-widget-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbWidgetPickerComponent } from './db-widget-picker.component';
|
||||||
59
src/components/db-widget/db-widget.component.html
Normal file
59
src/components/db-widget/db-widget.component.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<db-widget-header
|
||||||
|
[widget]="widget()"
|
||||||
|
[editMode]="editMode()"
|
||||||
|
[fullscreen]="fullscreen()"
|
||||||
|
(remove)="remove.emit()"
|
||||||
|
(fullscreenToggle)="fullscreenToggle.emit()"
|
||||||
|
(configOpen)="configOpen.emit()"
|
||||||
|
(action)="action.emit($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="db-widget__content">
|
||||||
|
@if (contentTemplate(); as tmpl) {
|
||||||
|
<ng-container *ngTemplateOutlet="tmpl; context: contentContext()" />
|
||||||
|
} @else {
|
||||||
|
<ng-template #widgetHost />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (footerTemplate(); as tmpl) {
|
||||||
|
<div class="db-widget__footer">
|
||||||
|
<ng-container *ngTemplateOutlet="tmpl; context: footerContext()" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showResizeHandles()) {
|
||||||
|
<db-resize-handle
|
||||||
|
direction="se"
|
||||||
|
[widgetId]="widget().id"
|
||||||
|
[colSpan]="widget().layout.colSpan"
|
||||||
|
[rowSpan]="widget().layout.rowSpan"
|
||||||
|
[minColSpan]="widget().layout.minColSpan ?? 1"
|
||||||
|
[minRowSpan]="widget().layout.minRowSpan ?? 1"
|
||||||
|
[maxColSpan]="widget().layout.maxColSpan ?? 12"
|
||||||
|
[maxRowSpan]="widget().layout.maxRowSpan ?? 20"
|
||||||
|
(resizeEnd)="onResizeEnd($event)"
|
||||||
|
/>
|
||||||
|
<db-resize-handle
|
||||||
|
direction="e"
|
||||||
|
[widgetId]="widget().id"
|
||||||
|
[colSpan]="widget().layout.colSpan"
|
||||||
|
[rowSpan]="widget().layout.rowSpan"
|
||||||
|
[minColSpan]="widget().layout.minColSpan ?? 1"
|
||||||
|
[minRowSpan]="widget().layout.minRowSpan ?? 1"
|
||||||
|
[maxColSpan]="widget().layout.maxColSpan ?? 12"
|
||||||
|
[maxRowSpan]="widget().layout.maxRowSpan ?? 20"
|
||||||
|
(resizeEnd)="onResizeEnd($event)"
|
||||||
|
/>
|
||||||
|
<db-resize-handle
|
||||||
|
direction="s"
|
||||||
|
[widgetId]="widget().id"
|
||||||
|
[colSpan]="widget().layout.colSpan"
|
||||||
|
[rowSpan]="widget().layout.rowSpan"
|
||||||
|
[minColSpan]="widget().layout.minColSpan ?? 1"
|
||||||
|
[minRowSpan]="widget().layout.minRowSpan ?? 1"
|
||||||
|
[maxColSpan]="widget().layout.maxColSpan ?? 12"
|
||||||
|
[maxRowSpan]="widget().layout.maxRowSpan ?? 20"
|
||||||
|
(resizeEnd)="onResizeEnd($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
31
src/components/db-widget/db-widget.component.scss
Normal file
31
src/components/db-widget/db-widget.component.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include db-widget-base;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.db-widget--dragging) {
|
||||||
|
@include db-drag-state;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.db-widget--fullscreen) {
|
||||||
|
@include db-fullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.db-widget--locked) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: var(--db-widget-content-overflow);
|
||||||
|
padding: var(--db-widget-content-padding);
|
||||||
|
@include db-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-widget__footer {
|
||||||
|
border-top: 1px solid var(--db-widget-border);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
107
src/components/db-widget/db-widget.component.ts
Normal file
107
src/components/db-widget/db-widget.component.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, input, output, inject,
|
||||||
|
viewChild, ViewContainerRef, effect, computed, TemplateRef,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
// import { OverlayStackService } from '@sda/base-ui'; // Temporarily disabled
|
||||||
|
import type { DbWidget, DbEditMode } from '../../types/dashboard.types';
|
||||||
|
import { DashboardRegistryService } from '../../services/dashboard-registry.service';
|
||||||
|
import { DashboardDragService } from '../../services/dashboard-drag.service';
|
||||||
|
import { DbWidgetHeaderComponent } from '../db-widget-header/db-widget-header.component';
|
||||||
|
import { DbResizeHandleComponent } from '../db-resize-handle/db-resize-handle.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'db-widget',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, DbWidgetHeaderComponent, DbResizeHandleComponent],
|
||||||
|
templateUrl: './db-widget.component.html',
|
||||||
|
styleUrl: './db-widget.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'class': 'db-widget',
|
||||||
|
'[class.db-widget--editing]': 'editMode() === "edit"',
|
||||||
|
'[class.db-widget--fullscreen]': 'fullscreen()',
|
||||||
|
'[class.db-widget--dragging]': 'isDragging()',
|
||||||
|
'[class.db-widget--locked]': 'widget().locked',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class DbWidgetComponent {
|
||||||
|
private registryService = inject(DashboardRegistryService);
|
||||||
|
private dragService = inject(DashboardDragService);
|
||||||
|
// private overlayStack = inject(OverlayStackService); // Temporarily disabled
|
||||||
|
|
||||||
|
readonly widgetHost = viewChild('widgetHost', { read: ViewContainerRef });
|
||||||
|
|
||||||
|
readonly widget = input.required<DbWidget>();
|
||||||
|
readonly editMode = input<DbEditMode>('view');
|
||||||
|
readonly fullscreen = input(false);
|
||||||
|
readonly contentTemplate = input<TemplateRef<unknown> | null>(null);
|
||||||
|
readonly footerTemplate = input<TemplateRef<unknown> | null>(null);
|
||||||
|
|
||||||
|
readonly remove = output<void>();
|
||||||
|
readonly fullscreenToggle = output<void>();
|
||||||
|
readonly configOpen = output<void>();
|
||||||
|
readonly action = output<string>();
|
||||||
|
readonly resizeEnd = output<{ colSpan: number; rowSpan: number }>();
|
||||||
|
|
||||||
|
protected readonly isDragging = computed(() =>
|
||||||
|
this.dragService.isDragging() && this.dragService.draggedWidgetId() === this.widget().id,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly showResizeHandles = computed(() =>
|
||||||
|
this.editMode() === 'edit' && !this.widget().locked && !this.fullscreen(),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly contentContext = computed(() => ({
|
||||||
|
$implicit: this.widget(),
|
||||||
|
widget: this.widget(),
|
||||||
|
config: this.widget().config,
|
||||||
|
editMode: this.editMode(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
protected readonly footerContext = computed(() => ({
|
||||||
|
$implicit: this.widget(),
|
||||||
|
widget: this.widget(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Load registered component dynamically when no content template is provided
|
||||||
|
effect(() => {
|
||||||
|
const host = this.widgetHost();
|
||||||
|
const widget = this.widget();
|
||||||
|
const template = this.contentTemplate();
|
||||||
|
|
||||||
|
if (!host || template) return;
|
||||||
|
|
||||||
|
host.clear();
|
||||||
|
const widgetType = this.registryService.getType(widget.type);
|
||||||
|
if (widgetType?.component) {
|
||||||
|
const componentRef = host.createComponent(widgetType.component);
|
||||||
|
// Pass widget data to the dynamic component if it has matching inputs
|
||||||
|
const instance = componentRef.instance as Record<string, unknown>;
|
||||||
|
if ('widget' in instance) instance['widget'] = widget;
|
||||||
|
if ('config' in instance) instance['config'] = widget.config;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage fullscreen overlay registration - Temporarily disabled
|
||||||
|
// effect(() => {
|
||||||
|
// const isFullscreen = this.fullscreen();
|
||||||
|
// const overlayId = `db-widget-fullscreen-${this.widget().id}`;
|
||||||
|
|
||||||
|
// if (isFullscreen) {
|
||||||
|
// this.overlayStack.register({
|
||||||
|
// id: overlayId,
|
||||||
|
// closeOnEscape: true,
|
||||||
|
// restoreFocus: true,
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// this.overlayStack.unregister(overlayId);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onResizeEnd(result: { colSpan: number; rowSpan: number }): void {
|
||||||
|
this.resizeEnd.emit(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/db-widget/index.ts
Normal file
1
src/components/db-widget/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DbWidgetComponent } from './db-widget.component';
|
||||||
10
src/components/index.ts
Normal file
10
src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { DbPlaceholderComponent } from './db-placeholder';
|
||||||
|
export { DbResizeHandleComponent } from './db-resize-handle';
|
||||||
|
export { DbWidgetHeaderComponent } from './db-widget-header';
|
||||||
|
export { DbLayoutPresetPickerComponent } from './db-layout-preset-picker';
|
||||||
|
export { DbToolbarComponent } from './db-toolbar';
|
||||||
|
export { DbWidgetComponent } from './db-widget';
|
||||||
|
export { DbConfigPanelComponent } from './db-config-panel';
|
||||||
|
export { DbGridComponent } from './db-grid';
|
||||||
|
export { DbWidgetPickerComponent } from './db-widget-picker';
|
||||||
|
export { DbDashboardComponent } from './db-dashboard';
|
||||||
25
src/directives/db-widget-content-def.directive.ts
Normal file
25
src/directives/db-widget-content-def.directive.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Directive, TemplateRef, inject, input } from '@angular/core';
|
||||||
|
import type { DbWidget, DbEditMode } from '../types/dashboard.types';
|
||||||
|
|
||||||
|
export interface DbWidgetContentDefContext {
|
||||||
|
$implicit: DbWidget;
|
||||||
|
widget: DbWidget;
|
||||||
|
config: Record<string, unknown> | undefined;
|
||||||
|
editMode: DbEditMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: 'ng-template[dbWidgetContentDef]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class DbWidgetContentDefDirective {
|
||||||
|
readonly dbWidgetContentDef = input.required<string>();
|
||||||
|
readonly templateRef = inject(TemplateRef<DbWidgetContentDefContext>);
|
||||||
|
|
||||||
|
static ngTemplateContextGuard(
|
||||||
|
_dir: DbWidgetContentDefDirective,
|
||||||
|
ctx: unknown,
|
||||||
|
): ctx is DbWidgetContentDefContext {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/directives/db-widget-footer-def.directive.ts
Normal file
23
src/directives/db-widget-footer-def.directive.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Directive, TemplateRef, inject, input } from '@angular/core';
|
||||||
|
import type { DbWidget } from '../types/dashboard.types';
|
||||||
|
|
||||||
|
export interface DbWidgetFooterDefContext {
|
||||||
|
$implicit: DbWidget;
|
||||||
|
widget: DbWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: 'ng-template[dbWidgetFooterDef]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class DbWidgetFooterDefDirective {
|
||||||
|
readonly dbWidgetFooterDef = input.required<string>();
|
||||||
|
readonly templateRef = inject(TemplateRef<DbWidgetFooterDefContext>);
|
||||||
|
|
||||||
|
static ngTemplateContextGuard(
|
||||||
|
_dir: DbWidgetFooterDefDirective,
|
||||||
|
ctx: unknown,
|
||||||
|
): ctx is DbWidgetFooterDefContext {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/directives/index.ts
Normal file
4
src/directives/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { DbWidgetContentDefDirective } from './db-widget-content-def.directive';
|
||||||
|
export type { DbWidgetContentDefContext } from './db-widget-content-def.directive';
|
||||||
|
export { DbWidgetFooterDefDirective } from './db-widget-footer-def.directive';
|
||||||
|
export type { DbWidgetFooterDefContext } from './db-widget-footer-def.directive';
|
||||||
50
src/index.ts
Normal file
50
src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Elements UI
|
||||||
|
* Main library entry point
|
||||||
|
*
|
||||||
|
* Angular components for dashboard layout builder with drag-and-drop,
|
||||||
|
* CSS Grid, widget management, and layout presets powered by @sda/base-ui
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { Component, signal } from '@angular/core';
|
||||||
|
* import {
|
||||||
|
* DbDashboardComponent, DbWidgetContentDefDirective,
|
||||||
|
* type DbDashboard, type DbWidgetType,
|
||||||
|
* } from '@sda/dashboard-elements-ui';
|
||||||
|
*
|
||||||
|
* @Component({
|
||||||
|
* standalone: true,
|
||||||
|
* imports: [DbDashboardComponent, DbWidgetContentDefDirective],
|
||||||
|
* template: `
|
||||||
|
* <db-dashboard [dashboard]="dashboard()" [widgetTypes]="widgetTypes">
|
||||||
|
* <ng-template dbWidgetContentDef="chart" let-widget>
|
||||||
|
* <my-chart [config]="widget.config"></my-chart>
|
||||||
|
* </ng-template>
|
||||||
|
* </db-dashboard>
|
||||||
|
* `
|
||||||
|
* })
|
||||||
|
* export class AppComponent {
|
||||||
|
* dashboard = signal<DbDashboard>({...});
|
||||||
|
* widgetTypes: DbWidgetType[] = [...];
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export * from './utils';
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
export * from './providers';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export * from './services';
|
||||||
|
|
||||||
|
// Directives
|
||||||
|
export * from './directives';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export * from './components';
|
||||||
27
src/providers/dashboard-config.provider.ts
Normal file
27
src/providers/dashboard-config.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
|
||||||
|
import type { DbConfig } from '../types/config.types';
|
||||||
|
|
||||||
|
export const DEFAULT_DASHBOARD_CONFIG: DbConfig = {
|
||||||
|
columns: 12,
|
||||||
|
rowHeight: 80,
|
||||||
|
gap: 16,
|
||||||
|
showToolbar: true,
|
||||||
|
defaultEditMode: 'view',
|
||||||
|
enableResponsive: true,
|
||||||
|
defaultPreset: 'custom',
|
||||||
|
storagePrefix: 'db',
|
||||||
|
enablePersistence: true,
|
||||||
|
animationDuration: 200,
|
||||||
|
locale: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DASHBOARD_CONFIG = new InjectionToken<DbConfig>('DASHBOARD_CONFIG', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => DEFAULT_DASHBOARD_CONFIG,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function provideDashboardConfig(config: Partial<DbConfig> = {}): EnvironmentProviders {
|
||||||
|
return makeEnvironmentProviders([
|
||||||
|
{ provide: DASHBOARD_CONFIG, useValue: { ...DEFAULT_DASHBOARD_CONFIG, ...config } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
1
src/providers/index.ts
Normal file
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dashboard-config.provider';
|
||||||
154
src/services/dashboard-drag.service.ts
Normal file
154
src/services/dashboard-drag.service.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import type { DbResizeDirection, DbDragPreview, DbResizePreview } from '../types/dashboard.types';
|
||||||
|
import { pixelToGridPosition, clamp } from '../utils/grid.utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardDragService {
|
||||||
|
// Move state
|
||||||
|
readonly isDragging = signal(false);
|
||||||
|
readonly draggedWidgetId = signal<string | null>(null);
|
||||||
|
readonly dragStartCol = signal(0);
|
||||||
|
readonly dragStartRow = signal(0);
|
||||||
|
readonly dragPreviewCol = signal(0);
|
||||||
|
readonly dragPreviewRow = signal(0);
|
||||||
|
readonly dragPreviewColSpan = signal(0);
|
||||||
|
readonly dragPreviewRowSpan = signal(0);
|
||||||
|
|
||||||
|
// Resize state
|
||||||
|
readonly isResizing = signal(false);
|
||||||
|
readonly resizeWidgetId = signal<string | null>(null);
|
||||||
|
readonly resizeDirection = signal<DbResizeDirection | null>(null);
|
||||||
|
readonly resizeStartColSpan = signal(0);
|
||||||
|
readonly resizeStartRowSpan = signal(0);
|
||||||
|
readonly resizePreviewColSpan = signal(0);
|
||||||
|
readonly resizePreviewRowSpan = signal(0);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
readonly isActive = computed(() => this.isDragging() || this.isResizing());
|
||||||
|
|
||||||
|
readonly dragPreview = computed<DbDragPreview | null>(() => {
|
||||||
|
if (!this.isDragging()) return null;
|
||||||
|
return {
|
||||||
|
col: this.dragPreviewCol(),
|
||||||
|
row: this.dragPreviewRow(),
|
||||||
|
colSpan: this.dragPreviewColSpan(),
|
||||||
|
rowSpan: this.dragPreviewRowSpan(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly resizePreview = computed<DbResizePreview | null>(() => {
|
||||||
|
if (!this.isResizing()) return null;
|
||||||
|
return {
|
||||||
|
colSpan: this.resizePreviewColSpan(),
|
||||||
|
rowSpan: this.resizePreviewRowSpan(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
startDrag(widgetId: string, col: number, row: number, colSpan: number, rowSpan: number): void {
|
||||||
|
this.isDragging.set(true);
|
||||||
|
this.draggedWidgetId.set(widgetId);
|
||||||
|
this.dragStartCol.set(col);
|
||||||
|
this.dragStartRow.set(row);
|
||||||
|
this.dragPreviewCol.set(col);
|
||||||
|
this.dragPreviewRow.set(row);
|
||||||
|
this.dragPreviewColSpan.set(colSpan);
|
||||||
|
this.dragPreviewRowSpan.set(rowSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDragPreview(col: number, row: number): void {
|
||||||
|
this.dragPreviewCol.set(col);
|
||||||
|
this.dragPreviewRow.set(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
endDrag(): { widgetId: string; col: number; row: number } | null {
|
||||||
|
const widgetId = this.draggedWidgetId();
|
||||||
|
const col = this.dragPreviewCol();
|
||||||
|
const row = this.dragPreviewRow();
|
||||||
|
|
||||||
|
if (!widgetId) {
|
||||||
|
this.cancelAll();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDragging.set(false);
|
||||||
|
this.draggedWidgetId.set(null);
|
||||||
|
this.dragStartCol.set(0);
|
||||||
|
this.dragStartRow.set(0);
|
||||||
|
this.dragPreviewCol.set(0);
|
||||||
|
this.dragPreviewRow.set(0);
|
||||||
|
this.dragPreviewColSpan.set(0);
|
||||||
|
this.dragPreviewRowSpan.set(0);
|
||||||
|
|
||||||
|
return { widgetId, col, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
startResize(
|
||||||
|
widgetId: string,
|
||||||
|
direction: DbResizeDirection,
|
||||||
|
colSpan: number,
|
||||||
|
rowSpan: number,
|
||||||
|
): void {
|
||||||
|
this.isResizing.set(true);
|
||||||
|
this.resizeWidgetId.set(widgetId);
|
||||||
|
this.resizeDirection.set(direction);
|
||||||
|
this.resizeStartColSpan.set(colSpan);
|
||||||
|
this.resizeStartRowSpan.set(rowSpan);
|
||||||
|
this.resizePreviewColSpan.set(colSpan);
|
||||||
|
this.resizePreviewRowSpan.set(rowSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResizePreview(colSpan: number, rowSpan: number): void {
|
||||||
|
this.resizePreviewColSpan.set(colSpan);
|
||||||
|
this.resizePreviewRowSpan.set(rowSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
endResize(): { widgetId: string; colSpan: number; rowSpan: number } | null {
|
||||||
|
const widgetId = this.resizeWidgetId();
|
||||||
|
const colSpan = this.resizePreviewColSpan();
|
||||||
|
const rowSpan = this.resizePreviewRowSpan();
|
||||||
|
|
||||||
|
if (!widgetId) {
|
||||||
|
this.cancelAll();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isResizing.set(false);
|
||||||
|
this.resizeWidgetId.set(null);
|
||||||
|
this.resizeDirection.set(null);
|
||||||
|
this.resizeStartColSpan.set(0);
|
||||||
|
this.resizeStartRowSpan.set(0);
|
||||||
|
this.resizePreviewColSpan.set(0);
|
||||||
|
this.resizePreviewRowSpan.set(0);
|
||||||
|
|
||||||
|
return { widgetId, colSpan, rowSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAll(): void {
|
||||||
|
this.isDragging.set(false);
|
||||||
|
this.draggedWidgetId.set(null);
|
||||||
|
this.dragStartCol.set(0);
|
||||||
|
this.dragStartRow.set(0);
|
||||||
|
this.dragPreviewCol.set(0);
|
||||||
|
this.dragPreviewRow.set(0);
|
||||||
|
this.dragPreviewColSpan.set(0);
|
||||||
|
this.dragPreviewRowSpan.set(0);
|
||||||
|
this.isResizing.set(false);
|
||||||
|
this.resizeWidgetId.set(null);
|
||||||
|
this.resizeDirection.set(null);
|
||||||
|
this.resizeStartColSpan.set(0);
|
||||||
|
this.resizeStartRowSpan.set(0);
|
||||||
|
this.resizePreviewColSpan.set(0);
|
||||||
|
this.resizePreviewRowSpan.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateGridPosition(
|
||||||
|
event: DragEvent | MouseEvent,
|
||||||
|
gridEl: HTMLElement,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
): { col: number; row: number } {
|
||||||
|
const gridRect = gridEl.getBoundingClientRect();
|
||||||
|
return pixelToGridPosition(event.clientX, event.clientY, gridRect, columns, rowHeight, gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/services/dashboard-layout.service.ts
Normal file
200
src/services/dashboard-layout.service.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
|
import type {
|
||||||
|
DbWidget, DbWidgetLayout, DbDashboard, DbBreakpointLayout,
|
||||||
|
DbEditMode, DbLayoutPreset, DbBreakpoint,
|
||||||
|
} from '../types/dashboard.types';
|
||||||
|
import { DASHBOARD_CONFIG } from '../providers/dashboard-config.provider';
|
||||||
|
import {
|
||||||
|
moveWidget, resizeWidget, removeWidget, addWidget,
|
||||||
|
stackLayoutForMobile, getEffectiveLayout, getMaxRow,
|
||||||
|
} from '../utils/layout.utils';
|
||||||
|
import { generateWidgetId } from '../utils/id.utils';
|
||||||
|
import { generatePresetLayout } from '../utils/preset.utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardLayoutService {
|
||||||
|
private config = inject(DASHBOARD_CONFIG);
|
||||||
|
|
||||||
|
// State
|
||||||
|
readonly widgets = signal<DbWidget[]>([]);
|
||||||
|
readonly layouts = signal<DbBreakpointLayout[]>([]);
|
||||||
|
readonly editMode = signal<DbEditMode>('view');
|
||||||
|
readonly fullscreenWidgetId = signal<string | null>(null);
|
||||||
|
readonly configPanelWidgetId = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Set default edit mode after injection is complete
|
||||||
|
this.editMode.set(this.config.defaultEditMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed - using 'lg' breakpoint for now (responsive support can be added later)
|
||||||
|
readonly activeBreakpoint = computed<DbBreakpoint>(() => {
|
||||||
|
return 'lg';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly activeLayout = computed<DbBreakpointLayout | null>(() => {
|
||||||
|
return getEffectiveLayout(this.layouts(), this.activeBreakpoint());
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isEditing = computed(() => this.editMode() === 'edit');
|
||||||
|
|
||||||
|
readonly widgetMap = computed(() => {
|
||||||
|
const map = new Map<string, DbWidget>();
|
||||||
|
for (const w of this.widgets()) {
|
||||||
|
map.set(w.id, w);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly gridColumns = computed(() => {
|
||||||
|
const layout = this.activeLayout();
|
||||||
|
return layout?.columns ?? this.config.columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly gridRowHeight = computed(() => {
|
||||||
|
const layout = this.activeLayout();
|
||||||
|
return layout?.rowHeight ?? this.config.rowHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly gridGap = computed(() => {
|
||||||
|
const layout = this.activeLayout();
|
||||||
|
return layout?.gap ?? this.config.gap;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly maxRow = computed(() => {
|
||||||
|
const widgetLayouts = this.widgets().map(w => w.layout);
|
||||||
|
return getMaxRow(widgetLayouts);
|
||||||
|
});
|
||||||
|
|
||||||
|
setDashboard(dashboard: DbDashboard): void {
|
||||||
|
this.widgets.set(dashboard.widgets);
|
||||||
|
this.layouts.set(dashboard.layouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEditMode(): void {
|
||||||
|
this.editMode.update(mode => (mode === 'edit' ? 'view' : 'edit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditMode(mode: DbEditMode): void {
|
||||||
|
this.editMode.set(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
addWidget(type: string, title: string, layout: Partial<DbWidgetLayout>): DbWidget {
|
||||||
|
const id = generateWidgetId();
|
||||||
|
const columns = this.gridColumns();
|
||||||
|
const existingLayouts = this.widgets().map(w => w.layout);
|
||||||
|
|
||||||
|
const widgetLayout: DbWidgetLayout = {
|
||||||
|
id,
|
||||||
|
col: layout.col ?? 1,
|
||||||
|
row: layout.row ?? 1,
|
||||||
|
colSpan: layout.colSpan ?? 4,
|
||||||
|
rowSpan: layout.rowSpan ?? 3,
|
||||||
|
minColSpan: layout.minColSpan,
|
||||||
|
minRowSpan: layout.minRowSpan,
|
||||||
|
maxColSpan: layout.maxColSpan,
|
||||||
|
maxRowSpan: layout.maxRowSpan,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLayouts = addWidget(existingLayouts, widgetLayout, columns);
|
||||||
|
const placedLayout = newLayouts.find(l => l.id === id)!;
|
||||||
|
|
||||||
|
const widget: DbWidget = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
layout: placedLayout,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.widgets.update(widgets => [...widgets, widget]);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWidget(widgetId: string): void {
|
||||||
|
this.widgets.update(widgets => widgets.filter(w => w.id !== widgetId));
|
||||||
|
if (this.configPanelWidgetId() === widgetId) {
|
||||||
|
this.configPanelWidgetId.set(null);
|
||||||
|
}
|
||||||
|
if (this.fullscreenWidgetId() === widgetId) {
|
||||||
|
this.fullscreenWidgetId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWidgetConfig(widgetId: string, key: string, value: unknown): void {
|
||||||
|
this.widgets.update(widgets =>
|
||||||
|
widgets.map(w =>
|
||||||
|
w.id === widgetId
|
||||||
|
? { ...w, config: { ...w.config, [key]: value } }
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveWidget(widgetId: string, newCol: number, newRow: number): void {
|
||||||
|
this.widgets.update(widgets =>
|
||||||
|
widgets.map(w =>
|
||||||
|
w.id === widgetId
|
||||||
|
? { ...w, layout: { ...w.layout, col: newCol, row: newRow } }
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeWidget(widgetId: string, newColSpan: number, newRowSpan: number): void {
|
||||||
|
this.widgets.update(widgets =>
|
||||||
|
widgets.map(w =>
|
||||||
|
w.id === widgetId
|
||||||
|
? { ...w, layout: { ...w.layout, colSpan: newColSpan, rowSpan: newRowSpan } }
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFullscreen(widgetId: string | null): void {
|
||||||
|
this.fullscreenWidgetId.set(widgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleConfigPanel(widgetId: string): void {
|
||||||
|
if (this.configPanelWidgetId() === widgetId) {
|
||||||
|
this.configPanelWidgetId.set(null);
|
||||||
|
} else {
|
||||||
|
this.configPanelWidgetId.set(widgetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConfigPanel(): void {
|
||||||
|
this.configPanelWidgetId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWidgetById(id: string): DbWidget | undefined {
|
||||||
|
return this.widgetMap().get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toDashboard(id: string, title: string): DbDashboard {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
widgets: this.widgets(),
|
||||||
|
layouts: this.layouts(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: DbLayoutPreset): void {
|
||||||
|
const columns = this.gridColumns();
|
||||||
|
const presetLayouts = generatePresetLayout(preset, columns);
|
||||||
|
|
||||||
|
if (preset === 'custom') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new widgets matching preset layout positions
|
||||||
|
const newWidgets: DbWidget[] = presetLayouts.map(layout => ({
|
||||||
|
id: layout.id,
|
||||||
|
type: 'placeholder',
|
||||||
|
title: 'New Widget',
|
||||||
|
layout,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.widgets.set(newWidgets);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/services/dashboard-persist.service.ts
Normal file
86
src/services/dashboard-persist.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||||
|
import type { DbDashboard } from '../types/dashboard.types';
|
||||||
|
import { DASHBOARD_CONFIG } from '../providers/dashboard-config.provider';
|
||||||
|
|
||||||
|
export interface DbPersistAdapter {
|
||||||
|
save(key: string, dashboard: DbDashboard): void;
|
||||||
|
load(key: string): DbDashboard | null;
|
||||||
|
remove(key: string): void;
|
||||||
|
list(): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DB_PERSIST_ADAPTER = new InjectionToken<DbPersistAdapter>(
|
||||||
|
'DB_PERSIST_ADAPTER',
|
||||||
|
);
|
||||||
|
|
||||||
|
class LocalStoragePersistAdapter implements DbPersistAdapter {
|
||||||
|
save(key: string, dashboard: DbDashboard): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(key, JSON.stringify(dashboard));
|
||||||
|
}
|
||||||
|
|
||||||
|
load(key: string): DbDashboard | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
const data = localStorage.getItem(key);
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as DbDashboard;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(key: string): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): string[] {
|
||||||
|
if (typeof localStorage === 'undefined') return [];
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key) keys.push(key);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardPersistService {
|
||||||
|
private config = inject(DASHBOARD_CONFIG);
|
||||||
|
private adapter: DbPersistAdapter;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const injected = inject(DB_PERSIST_ADAPTER, { optional: true });
|
||||||
|
this.adapter = injected ?? new LocalStoragePersistAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKey(id: string): string {
|
||||||
|
return `${this.config.storagePrefix}_dashboard_${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(id: string, dashboard: DbDashboard): void {
|
||||||
|
this.adapter.save(this.getKey(id), dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(id: string): DbDashboard | null {
|
||||||
|
return this.adapter.load(this.getKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): void {
|
||||||
|
this.adapter.remove(this.getKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): string[] {
|
||||||
|
const prefix = `${this.config.storagePrefix}_dashboard_`;
|
||||||
|
return this.adapter
|
||||||
|
.list()
|
||||||
|
.filter(key => key.startsWith(prefix))
|
||||||
|
.map(key => key.slice(prefix.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSaved(id: string): boolean {
|
||||||
|
return this.adapter.load(this.getKey(id)) !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/services/dashboard-registry.service.ts
Normal file
72
src/services/dashboard-registry.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import type { DbWidgetType, DbWidgetCategory } from '../types/dashboard.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardRegistryService {
|
||||||
|
readonly widgetTypes = signal<DbWidgetType[]>([]);
|
||||||
|
readonly categories = signal<DbWidgetCategory[]>([]);
|
||||||
|
|
||||||
|
readonly widgetTypeMap = computed(() => {
|
||||||
|
const map = new Map<string, DbWidgetType>();
|
||||||
|
for (const wt of this.widgetTypes()) {
|
||||||
|
map.set(wt.type, wt);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly groupedByCategory = computed(() => {
|
||||||
|
const map = new Map<string, DbWidgetType[]>();
|
||||||
|
for (const wt of this.widgetTypes()) {
|
||||||
|
const key = wt.category ?? 'uncategorized';
|
||||||
|
const group = map.get(key) ?? [];
|
||||||
|
group.push(wt);
|
||||||
|
map.set(key, group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
registerType(widgetType: DbWidgetType): void {
|
||||||
|
this.widgetTypes.update(types => {
|
||||||
|
const filtered = types.filter(t => t.type !== widgetType.type);
|
||||||
|
return [...filtered, widgetType];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTypes(widgetTypes: DbWidgetType[]): void {
|
||||||
|
this.widgetTypes.update(existing => {
|
||||||
|
const newTypeKeys = new Set(widgetTypes.map(t => t.type));
|
||||||
|
const filtered = existing.filter(t => !newTypeKeys.has(t.type));
|
||||||
|
return [...filtered, ...widgetTypes];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCategory(category: DbWidgetCategory): void {
|
||||||
|
this.categories.update(cats => {
|
||||||
|
const filtered = cats.filter(c => c.key !== category.key);
|
||||||
|
return [...filtered, category];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getType(type: string): DbWidgetType | undefined {
|
||||||
|
return this.widgetTypeMap().get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasType(type: string): boolean {
|
||||||
|
return this.widgetTypeMap().has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterType(type: string): void {
|
||||||
|
this.widgetTypes.update(types => types.filter(t => t.type !== type));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTypes(query: string): DbWidgetType[] {
|
||||||
|
if (!query.trim()) return this.widgetTypes();
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
return this.widgetTypes().filter(
|
||||||
|
t =>
|
||||||
|
t.label.toLowerCase().includes(lower) ||
|
||||||
|
t.type.toLowerCase().includes(lower) ||
|
||||||
|
t.description?.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/services/index.ts
Normal file
5
src/services/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { DashboardRegistryService } from './dashboard-registry.service';
|
||||||
|
export { DashboardPersistService, DB_PERSIST_ADAPTER } from './dashboard-persist.service';
|
||||||
|
export type { DbPersistAdapter } from './dashboard-persist.service';
|
||||||
|
export { DashboardDragService } from './dashboard-drag.service';
|
||||||
|
export { DashboardLayoutService } from './dashboard-layout.service';
|
||||||
2
src/styles/_index.scss
Normal file
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@forward 'tokens';
|
||||||
|
@forward 'mixins';
|
||||||
133
src/styles/_mixins.scss
Normal file
133
src/styles/_mixins.scss
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
@mixin db-container {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--db-font-family, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-widget-base {
|
||||||
|
background: var(--db-widget-bg);
|
||||||
|
border: 1px solid var(--db-widget-border);
|
||||||
|
border-radius: var(--db-widget-radius);
|
||||||
|
padding: var(--db-widget-padding);
|
||||||
|
box-shadow: var(--db-widget-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition:
|
||||||
|
box-shadow 200ms var(--db-ease-smooth),
|
||||||
|
transform 200ms var(--db-ease-spring),
|
||||||
|
opacity 200ms var(--db-ease-smooth),
|
||||||
|
border-color var(--db-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--db-widget-shadow-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-drag-state {
|
||||||
|
transform: scale(var(--db-drag-scale));
|
||||||
|
box-shadow: var(--db-widget-shadow-dragging);
|
||||||
|
opacity: var(--db-drag-opacity);
|
||||||
|
cursor: grabbing;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-placeholder {
|
||||||
|
border: var(--db-placeholder-border-width) dashed var(--db-placeholder-border);
|
||||||
|
background: var(--db-placeholder-bg);
|
||||||
|
border-radius: var(--db-placeholder-radius);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-glass {
|
||||||
|
background: var(--db-glass-bg);
|
||||||
|
backdrop-filter: blur(var(--db-glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--db-glass-blur));
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-scrollbar($width: 4px) {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: $width;
|
||||||
|
height: $width;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-text-muted, #9ca3af);
|
||||||
|
border-radius: $width;
|
||||||
|
opacity: 0.4;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-text-muted, #9ca3af) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-panel-slide($direction) {
|
||||||
|
@if $direction == 'right' {
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--db-transition-slow) var(--db-ease-smooth);
|
||||||
|
|
||||||
|
&.db-panel--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
} @else if $direction == 'left' {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--db-transition-slow) var(--db-ease-smooth);
|
||||||
|
|
||||||
|
&.db-panel--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-resize-handle-base {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--db-transition);
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.db-widget--editing & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin db-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--db-fullscreen-bg);
|
||||||
|
z-index: var(--db-fullscreen-z);
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
153
src/styles/_tokens.scss
Normal file
153
src/styles/_tokens.scss
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
:root {
|
||||||
|
// Grid
|
||||||
|
--db-grid-columns: 12;
|
||||||
|
--db-grid-row-height: 80px;
|
||||||
|
--db-grid-gap: 16px;
|
||||||
|
--db-grid-min-height: 400px;
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
--db-bg: var(--color-bg-primary, #f5f5f7);
|
||||||
|
--db-border: var(--color-border-primary, #e5e7eb);
|
||||||
|
|
||||||
|
// Widget
|
||||||
|
--db-widget-bg: var(--color-bg-secondary, #ffffff);
|
||||||
|
--db-widget-border: var(--color-border-primary, #e5e7eb);
|
||||||
|
--db-widget-radius: 0.875rem;
|
||||||
|
--db-widget-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
--db-widget-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
--db-widget-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
--db-widget-padding: 0;
|
||||||
|
|
||||||
|
// Widget header
|
||||||
|
--db-widget-header-bg: transparent;
|
||||||
|
--db-widget-header-padding: 0.75rem 1rem;
|
||||||
|
--db-widget-header-font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
--db-widget-header-font-weight: 600;
|
||||||
|
--db-widget-header-color: var(--color-text-primary, #111827);
|
||||||
|
--db-widget-header-border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||||
|
--db-widget-header-icon-size: 1rem;
|
||||||
|
|
||||||
|
// Widget content
|
||||||
|
--db-widget-content-padding: 1rem;
|
||||||
|
--db-widget-content-overflow: auto;
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
--db-toolbar-bg: var(--color-bg-secondary, #ffffff);
|
||||||
|
--db-toolbar-border: var(--color-border-primary, #e5e7eb);
|
||||||
|
--db-toolbar-padding: 0.75rem 1.25rem;
|
||||||
|
--db-toolbar-gap: 0.75rem;
|
||||||
|
|
||||||
|
// Placeholder
|
||||||
|
--db-placeholder-bg: rgba(59, 130, 246, 0.06);
|
||||||
|
--db-placeholder-border: var(--color-primary-500, #3b82f6);
|
||||||
|
--db-placeholder-border-width: 2px;
|
||||||
|
--db-placeholder-radius: 0.75rem;
|
||||||
|
|
||||||
|
// Resize handle
|
||||||
|
--db-resize-handle-size: 8px;
|
||||||
|
--db-resize-handle-color: var(--color-primary-500, #3b82f6);
|
||||||
|
--db-resize-handle-bg: var(--color-bg-secondary, #ffffff);
|
||||||
|
|
||||||
|
// Config panel
|
||||||
|
--db-config-panel-width: 360px;
|
||||||
|
--db-config-panel-bg: var(--color-bg-secondary, #ffffff);
|
||||||
|
--db-config-panel-border: var(--color-border-primary, #e5e7eb);
|
||||||
|
--db-config-panel-shadow: -4px 0 24px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// Widget picker
|
||||||
|
--db-picker-card-bg: var(--color-bg-secondary, #ffffff);
|
||||||
|
--db-picker-card-border: var(--color-border-primary, #e5e7eb);
|
||||||
|
--db-picker-card-radius: 0.75rem;
|
||||||
|
--db-picker-card-hover-border: var(--color-primary-500, #3b82f6);
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
--db-fullscreen-bg: var(--color-bg-primary, #f5f5f7);
|
||||||
|
--db-fullscreen-z: 1060;
|
||||||
|
|
||||||
|
// Preset picker
|
||||||
|
--db-preset-thumbnail-size: 48px;
|
||||||
|
--db-preset-thumbnail-bg: var(--color-bg-tertiary, #f3f4f6);
|
||||||
|
--db-preset-thumbnail-active-border: var(--color-primary-500, #3b82f6);
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
--db-drag-opacity: 0.85;
|
||||||
|
--db-drag-scale: 1.02;
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
--db-transition: 150ms ease-in-out;
|
||||||
|
--db-transition-slow: 300ms ease-in-out;
|
||||||
|
--db-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--db-ease-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
|
||||||
|
// Glass
|
||||||
|
--db-glass-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--db-glass-blur: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--db-bg: var(--color-bg-primary, #0c0f17);
|
||||||
|
|
||||||
|
--db-widget-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-widget-border: var(--color-border-primary, #1e2536);
|
||||||
|
--db-widget-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--db-widget-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
--db-widget-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--db-widget-header-color: var(--color-text-primary, #f9fafb);
|
||||||
|
--db-widget-header-border: 1px solid var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-toolbar-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-toolbar-border: var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-placeholder-bg: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
|
--db-config-panel-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-config-panel-border: var(--color-border-primary, #1e2536);
|
||||||
|
--db-config-panel-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--db-picker-card-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-picker-card-border: var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-fullscreen-bg: var(--color-bg-primary, #0c0f17);
|
||||||
|
|
||||||
|
--db-preset-thumbnail-bg: var(--color-bg-tertiary, #1e2536);
|
||||||
|
|
||||||
|
--db-glass-bg: rgba(12, 15, 23, 0.75);
|
||||||
|
|
||||||
|
--db-resize-handle-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mode="dark"] {
|
||||||
|
--db-bg: var(--color-bg-primary, #0c0f17);
|
||||||
|
|
||||||
|
--db-widget-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-widget-border: var(--color-border-primary, #1e2536);
|
||||||
|
--db-widget-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--db-widget-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
--db-widget-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--db-widget-header-color: var(--color-text-primary, #f9fafb);
|
||||||
|
--db-widget-header-border: 1px solid var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-toolbar-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-toolbar-border: var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-placeholder-bg: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
|
--db-config-panel-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-config-panel-border: var(--color-border-primary, #1e2536);
|
||||||
|
--db-config-panel-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--db-picker-card-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
--db-picker-card-border: var(--color-border-primary, #1e2536);
|
||||||
|
|
||||||
|
--db-fullscreen-bg: var(--color-bg-primary, #0c0f17);
|
||||||
|
|
||||||
|
--db-preset-thumbnail-bg: var(--color-bg-tertiary, #1e2536);
|
||||||
|
|
||||||
|
--db-glass-bg: rgba(12, 15, 23, 0.75);
|
||||||
|
|
||||||
|
--db-resize-handle-bg: var(--color-bg-secondary, #161b26);
|
||||||
|
}
|
||||||
15
src/types/config.types.ts
Normal file
15
src/types/config.types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { DbEditMode, DbLayoutPreset } from './dashboard.types';
|
||||||
|
|
||||||
|
export interface DbConfig {
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
gap: number;
|
||||||
|
showToolbar: boolean;
|
||||||
|
defaultEditMode: DbEditMode;
|
||||||
|
enableResponsive: boolean;
|
||||||
|
defaultPreset: DbLayoutPreset;
|
||||||
|
storagePrefix: string;
|
||||||
|
enablePersistence: boolean;
|
||||||
|
animationDuration: number;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
75
src/types/dashboard.types.ts
Normal file
75
src/types/dashboard.types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Type } from '@angular/core';
|
||||||
|
|
||||||
|
export type DbEditMode = 'view' | 'edit';
|
||||||
|
|
||||||
|
export type DbResizeDirection = 'se' | 'e' | 's';
|
||||||
|
|
||||||
|
export type DbLayoutPreset = 'single' | 'two-column' | 'sidebar-main' | 'four-grid' | 'analytics' | 'custom';
|
||||||
|
|
||||||
|
export type DbBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
export interface DbWidgetLayout {
|
||||||
|
id: string;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
colSpan: number;
|
||||||
|
rowSpan: number;
|
||||||
|
minColSpan?: number;
|
||||||
|
minRowSpan?: number;
|
||||||
|
maxColSpan?: number;
|
||||||
|
maxRowSpan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidget {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
layout: DbWidgetLayout;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbBreakpointLayout {
|
||||||
|
breakpoint: DbBreakpoint;
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
gap: number;
|
||||||
|
widgets: DbWidgetLayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbDashboard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
layouts: DbBreakpointLayout[];
|
||||||
|
widgets: DbWidget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetType {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
defaultLayout: Pick<DbWidgetLayout, 'colSpan' | 'rowSpan' | 'minColSpan' | 'minRowSpan' | 'maxColSpan' | 'maxRowSpan'>;
|
||||||
|
component: Type<unknown>;
|
||||||
|
configComponent?: Type<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetCategory {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbDragPreview {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
colSpan: number;
|
||||||
|
rowSpan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbResizePreview {
|
||||||
|
colSpan: number;
|
||||||
|
rowSpan: number;
|
||||||
|
}
|
||||||
86
src/types/event.types.ts
Normal file
86
src/types/event.types.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
DbWidget, DbWidgetLayout, DbWidgetType, DbLayoutPreset,
|
||||||
|
DbBreakpoint, DbDashboard, DbEditMode,
|
||||||
|
} from './dashboard.types';
|
||||||
|
|
||||||
|
export interface DbWidgetMoveEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
fromCol: number;
|
||||||
|
fromRow: number;
|
||||||
|
toCol: number;
|
||||||
|
toRow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetResizeEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
fromColSpan: number;
|
||||||
|
fromRowSpan: number;
|
||||||
|
toColSpan: number;
|
||||||
|
toRowSpan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetAddEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
widgetType: DbWidgetType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetRemoveEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetConfigChangeEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
previousValue: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetFullscreenEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
fullscreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbEditModeChangeEvent {
|
||||||
|
mode: DbEditMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbPresetApplyEvent {
|
||||||
|
preset: DbLayoutPreset;
|
||||||
|
previousPreset: DbLayoutPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbSaveEvent {
|
||||||
|
dashboard: DbDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbLoadEvent {
|
||||||
|
dashboard: DbDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbBreakpointChangeEvent {
|
||||||
|
previous: DbBreakpoint;
|
||||||
|
current: DbBreakpoint;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbWidgetActionEvent {
|
||||||
|
widget: DbWidget;
|
||||||
|
action: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbConfigPanelEvent {
|
||||||
|
widget: DbWidget | null;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbDragStartEvent {
|
||||||
|
widgetId: string;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbDragEndEvent {
|
||||||
|
widgetId: string;
|
||||||
|
dropped: boolean;
|
||||||
|
}
|
||||||
3
src/types/index.ts
Normal file
3
src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './dashboard.types';
|
||||||
|
export * from './config.types';
|
||||||
|
export * from './event.types';
|
||||||
100
src/utils/grid.utils.ts
Normal file
100
src/utils/grid.utils.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { DbWidgetLayout } from '../types/dashboard.types';
|
||||||
|
|
||||||
|
/** Clamp a number between min and max */
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a column value to valid range */
|
||||||
|
export function snapToColumn(col: number, maxColumns: number): number {
|
||||||
|
return clamp(Math.round(col), 1, maxColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a span within its allowed bounds */
|
||||||
|
export function clampSpan(span: number, min: number, max: number): number {
|
||||||
|
return clamp(Math.round(span), min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a layout overlaps with any other layout */
|
||||||
|
export function hasOverlap(
|
||||||
|
layout: DbWidgetLayout,
|
||||||
|
others: DbWidgetLayout[],
|
||||||
|
excludeId?: string,
|
||||||
|
): boolean {
|
||||||
|
const aLeft = layout.col;
|
||||||
|
const aRight = layout.col + layout.colSpan - 1;
|
||||||
|
const aTop = layout.row;
|
||||||
|
const aBottom = layout.row + layout.rowSpan - 1;
|
||||||
|
|
||||||
|
return others.some(other => {
|
||||||
|
if (excludeId && other.id === excludeId) return false;
|
||||||
|
if (other.id === layout.id) return false;
|
||||||
|
|
||||||
|
const bLeft = other.col;
|
||||||
|
const bRight = other.col + other.colSpan - 1;
|
||||||
|
const bTop = other.row;
|
||||||
|
const bBottom = other.row + other.rowSpan - 1;
|
||||||
|
|
||||||
|
return aLeft <= bRight && aRight >= bLeft && aTop <= bBottom && aBottom >= bTop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the first available position for a widget with given span */
|
||||||
|
export function findNextAvailablePosition(
|
||||||
|
colSpan: number,
|
||||||
|
rowSpan: number,
|
||||||
|
existing: DbWidgetLayout[],
|
||||||
|
maxColumns: number,
|
||||||
|
): { col: number; row: number } {
|
||||||
|
for (let row = 1; row < 100; row++) {
|
||||||
|
for (let col = 1; col <= maxColumns - colSpan + 1; col++) {
|
||||||
|
const candidate: DbWidgetLayout = {
|
||||||
|
id: '__candidate__',
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
colSpan,
|
||||||
|
rowSpan,
|
||||||
|
};
|
||||||
|
if (!hasOverlap(candidate, existing)) {
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: place at the bottom
|
||||||
|
const maxRow = existing.reduce((max, w) => Math.max(max, w.row + w.rowSpan), 1);
|
||||||
|
return { col: 1, row: maxRow };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert pixel coordinates to grid cell position */
|
||||||
|
export function pixelToGridPosition(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
gridRect: DOMRect,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
): { col: number; row: number } {
|
||||||
|
const relX = clientX - gridRect.left;
|
||||||
|
const relY = clientY - gridRect.top;
|
||||||
|
|
||||||
|
const cellWidth = (gridRect.width - gap * (columns - 1)) / columns;
|
||||||
|
const col = Math.floor(relX / (cellWidth + gap)) + 1;
|
||||||
|
const row = Math.floor(relY / (rowHeight + gap)) + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
col: clamp(col, 1, columns),
|
||||||
|
row: Math.max(1, row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate new span from a resize delta in pixels */
|
||||||
|
export function calculateResizeSpan(
|
||||||
|
startSpan: number,
|
||||||
|
deltaPx: number,
|
||||||
|
cellSizePx: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const deltaSpan = Math.round(deltaPx / cellSizePx);
|
||||||
|
return clampSpan(startSpan + deltaSpan, min, max);
|
||||||
|
}
|
||||||
6
src/utils/id.utils.ts
Normal file
6
src/utils/id.utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** Generate a unique widget ID */
|
||||||
|
export function generateWidgetId(): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `db-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
4
src/utils/index.ts
Normal file
4
src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './grid.utils';
|
||||||
|
export * from './layout.utils';
|
||||||
|
export * from './id.utils';
|
||||||
|
export * from './preset.utils';
|
||||||
98
src/utils/layout.utils.ts
Normal file
98
src/utils/layout.utils.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { DbWidgetLayout, DbBreakpointLayout, DbBreakpoint } from '../types/dashboard.types';
|
||||||
|
import { findNextAvailablePosition } from './grid.utils';
|
||||||
|
|
||||||
|
const BREAKPOINT_ORDER: DbBreakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl'];
|
||||||
|
|
||||||
|
/** Move a widget to a new grid position (immutable) */
|
||||||
|
export function moveWidget(
|
||||||
|
widgets: DbWidgetLayout[],
|
||||||
|
widgetId: string,
|
||||||
|
newCol: number,
|
||||||
|
newRow: number,
|
||||||
|
): DbWidgetLayout[] {
|
||||||
|
return widgets.map(w =>
|
||||||
|
w.id === widgetId ? { ...w, col: newCol, row: newRow } : w
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize a widget to new spans (immutable) */
|
||||||
|
export function resizeWidget(
|
||||||
|
widgets: DbWidgetLayout[],
|
||||||
|
widgetId: string,
|
||||||
|
newColSpan: number,
|
||||||
|
newRowSpan: number,
|
||||||
|
): DbWidgetLayout[] {
|
||||||
|
return widgets.map(w =>
|
||||||
|
w.id === widgetId ? { ...w, colSpan: newColSpan, rowSpan: newRowSpan } : w
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a widget from the layout array (immutable) */
|
||||||
|
export function removeWidget(
|
||||||
|
widgets: DbWidgetLayout[],
|
||||||
|
widgetId: string,
|
||||||
|
): DbWidgetLayout[] {
|
||||||
|
return widgets.filter(w => w.id !== widgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a widget to the layout, finding the first available position */
|
||||||
|
export function addWidget(
|
||||||
|
widgets: DbWidgetLayout[],
|
||||||
|
newWidget: DbWidgetLayout,
|
||||||
|
maxColumns: number,
|
||||||
|
): DbWidgetLayout[] {
|
||||||
|
const { col, row } = findNextAvailablePosition(
|
||||||
|
newWidget.colSpan,
|
||||||
|
newWidget.rowSpan,
|
||||||
|
widgets,
|
||||||
|
maxColumns,
|
||||||
|
);
|
||||||
|
return [...widgets, { ...newWidget, col, row }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stack all widgets into a single column for mobile view */
|
||||||
|
export function stackLayoutForMobile(widgets: DbWidgetLayout[]): DbWidgetLayout[] {
|
||||||
|
let currentRow = 1;
|
||||||
|
return widgets.map(w => {
|
||||||
|
const stacked: DbWidgetLayout = {
|
||||||
|
...w,
|
||||||
|
col: 1,
|
||||||
|
row: currentRow,
|
||||||
|
colSpan: 1,
|
||||||
|
rowSpan: w.rowSpan,
|
||||||
|
};
|
||||||
|
currentRow += w.rowSpan;
|
||||||
|
return stacked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the effective layout for a target breakpoint, falling back to the nearest larger one */
|
||||||
|
export function getEffectiveLayout(
|
||||||
|
layouts: DbBreakpointLayout[],
|
||||||
|
targetBreakpoint: DbBreakpoint,
|
||||||
|
): DbBreakpointLayout | null {
|
||||||
|
// Try exact match first
|
||||||
|
const exact = layouts.find(l => l.breakpoint === targetBreakpoint);
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
// Fallback to the nearest larger breakpoint
|
||||||
|
const targetIndex = BREAKPOINT_ORDER.indexOf(targetBreakpoint);
|
||||||
|
for (let i = targetIndex + 1; i < BREAKPOINT_ORDER.length; i++) {
|
||||||
|
const fallback = layouts.find(l => l.breakpoint === BREAKPOINT_ORDER[i]);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to nearest smaller
|
||||||
|
for (let i = targetIndex - 1; i >= 0; i--) {
|
||||||
|
const fallback = layouts.find(l => l.breakpoint === BREAKPOINT_ORDER[i]);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the maximum row occupied by any widget */
|
||||||
|
export function getMaxRow(widgets: DbWidgetLayout[]): number {
|
||||||
|
if (widgets.length === 0) return 0;
|
||||||
|
return widgets.reduce((max, w) => Math.max(max, w.row + w.rowSpan - 1), 0);
|
||||||
|
}
|
||||||
47
src/utils/preset.utils.ts
Normal file
47
src/utils/preset.utils.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { DbLayoutPreset, DbWidgetLayout } from '../types/dashboard.types';
|
||||||
|
import { generateWidgetId } from './id.utils';
|
||||||
|
|
||||||
|
/** Generate a preset layout configuration with placeholder widget layouts */
|
||||||
|
export function generatePresetLayout(preset: DbLayoutPreset, columns: number = 12): DbWidgetLayout[] {
|
||||||
|
switch (preset) {
|
||||||
|
case 'single':
|
||||||
|
return [
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 1, colSpan: columns, rowSpan: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'two-column':
|
||||||
|
return [
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 1, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
{ id: generateWidgetId(), col: columns / 2 + 1, row: 1, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'sidebar-main':
|
||||||
|
return [
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 1, colSpan: 4, rowSpan: 4 },
|
||||||
|
{ id: generateWidgetId(), col: 5, row: 1, colSpan: 8, rowSpan: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'four-grid':
|
||||||
|
return [
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 1, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
{ id: generateWidgetId(), col: columns / 2 + 1, row: 1, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 4, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
{ id: generateWidgetId(), col: columns / 2 + 1, row: 4, colSpan: columns / 2, rowSpan: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'analytics':
|
||||||
|
return [
|
||||||
|
// 3 small cards on top
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 1, colSpan: 4, rowSpan: 2 },
|
||||||
|
{ id: generateWidgetId(), col: 5, row: 1, colSpan: 4, rowSpan: 2 },
|
||||||
|
{ id: generateWidgetId(), col: 9, row: 1, colSpan: 4, rowSpan: 2 },
|
||||||
|
// 1 wide card + 1 sidebar card on bottom
|
||||||
|
{ id: generateWidgetId(), col: 1, row: 3, colSpan: 8, rowSpan: 4 },
|
||||||
|
{ id: generateWidgetId(), col: 9, row: 3, colSpan: 4, rowSpan: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022", "dom"],
|
||||||
|
"useDefineForClassFields": false
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user