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