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:
Giuliano Silvestro
2026-03-09 08:44:10 +10:00
commit 0b33a4561e
79 changed files with 21730 additions and 0 deletions

31
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

29
demo/package.json Normal file
View 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"
}
}

View 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
View File

13
demo/src/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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"
}
}

View File

@@ -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>
}

View File

@@ -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);
}

View 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 });
}
}

View File

@@ -0,0 +1 @@
export { DbConfigPanelComponent } from './db-config-panel.component';

View 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>

View 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); }
}

View 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();
}
}

View File

@@ -0,0 +1 @@
export { DbDashboardComponent } from './db-dashboard.component';

View 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>

View 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;
}

View 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;
}
}

View File

@@ -0,0 +1 @@
export { DbGridComponent } from './db-grid.component';

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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"/>`,
},
];
}

View File

@@ -0,0 +1 @@
export { DbLayoutPresetPickerComponent } from './db-layout-preset-picker.component';

View File

@@ -0,0 +1 @@
<div class="db-placeholder__inner"></div>

View 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);
}

View 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()}`);
}

View File

@@ -0,0 +1 @@
export { DbPlaceholderComponent } from './db-placeholder.component';

View File

@@ -0,0 +1 @@
<div class="db-resize-handle__indicator"></div>

View File

@@ -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);
}
}

View 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();
}
}

View File

@@ -0,0 +1 @@
export { DbResizeHandleComponent } from './db-resize-handle.component';

View 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>

View 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;
}

View 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';
}
}

View File

@@ -0,0 +1 @@
export { DbToolbarComponent } from './db-toolbar.component';

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1 @@
export { DbWidgetHeaderComponent } from './db-widget-header.component';

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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) ?? [];
}
}

View File

@@ -0,0 +1 @@
export { DbWidgetPickerComponent } from './db-widget-picker.component';

View 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)"
/>
}

View 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;
}

View 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);
}
}

View File

@@ -0,0 +1 @@
export { DbWidgetComponent } from './db-widget.component';

10
src/components/index.ts Normal file
View 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';

View 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;
}
}

View 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
View 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
View 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';

View 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
View File

@@ -0,0 +1 @@
export * from './dashboard-config.provider';

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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
View 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
View File

@@ -0,0 +1,2 @@
@forward 'tokens';
@forward 'mixins';

133
src/styles/_mixins.scss Normal file
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}