From e909453fcc058fe7badaca1b4679259e020d3a76 Mon Sep 17 00:00:00 2001 From: Karina Kharchenko Date: Mon, 18 May 2026 15:42:59 +0300 Subject: [PATCH 1/9] feat(edit-schema): full-screen canvas with pan/zoom, table list, minimap Restructure the schema editor into a Figma-style canvas with the AI chat docked on the right. Adds a new SchemaDiagramViewer with mouse pan (grab cursor), wheel-anchored zoom, double-click focus, a floating searchable tables sidebar, fit-to-screen / reset controls, and a minimap with a draggable viewport rectangle. Diagram re-renders re-fit automatically when the source changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../edit-database-schema.component.css | 356 ++++++++------ .../edit-database-schema.component.html | 346 +++++++------- .../edit-database-schema.component.ts | 29 +- .../schema-diagram-viewer.component.css | 333 +++++++++++++ .../schema-diagram-viewer.component.html | 79 ++++ .../schema-diagram-viewer.component.ts | 441 ++++++++++++++++++ 6 files changed, 1238 insertions(+), 346 deletions(-) create mode 100644 frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css create mode 100644 frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html create mode 100644 frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css index 5241f9fa6..dfc74ce9d 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css @@ -1,41 +1,38 @@ :host { display: flex; height: calc(100vh - 44px); + width: 100%; } -.schema-chat { +.schema-editor { display: flex; flex-direction: column; - align-items: center; width: 100%; height: 100%; overflow: hidden; position: relative; } -.schema-chat--routed { - max-width: 860px; - margin: 0 auto; - padding: 0 16px; +.schema-editor--routed { height: calc(100vh - var(--mat-toolbar-standard-height)); } /* ── Header (routed page) ── */ -.schema-chat__header { +.schema-editor__header { display: flex; align-items: center; gap: 8px; - padding: var(--top-margin) 0 16px; + padding: var(--top-margin) 16px 12px; flex-shrink: 0; width: 100%; } -.schema-chat__back-button { +.schema-editor__back-button { flex-shrink: 0; } -.schema-chat__title { +.schema-editor__title { font-size: 20px; font-weight: 600; margin: 0 !important; @@ -43,31 +40,62 @@ /* ── Close button (embedded panel) ── */ -.schema-chat__close-button { +.schema-editor__close-button { position: absolute; top: 8px; right: 8px; z-index: 10; } +/* ── Body (two-column) ── */ + +.schema-editor__body { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden; + gap: 16px; + padding: 0 16px 16px; +} + +/* ── Canvas (left) ── */ + +.schema-editor__canvas { + flex: 1; + display: flex; + min-width: 0; +} + +.schema-editor__diagram { + flex: 1; + display: flex; + min-width: 0; +} + +.schema-editor__diagram-loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + /* Welcome */ -.schema-chat__welcome { +.schema-editor__welcome { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - flex: 1 1 0; gap: 16px; padding: 24px; - max-width: 500px; + max-width: 520px; margin: 0 auto; - min-height: 0; overflow-y: auto; } - -.schema-chat__welcome-icon { +.schema-editor__welcome-icon { width: 48px !important; height: 48px !important; animation: rocket-float 3s ease-in-out infinite; @@ -78,7 +106,7 @@ 50% { transform: translateY(-2px) rotate(2deg); } } -.schema-chat__welcome-title { +.schema-editor__welcome-title { font-size: 16px; font-weight: 500; color: rgba(0, 0, 0, 0.7); @@ -86,7 +114,7 @@ text-align: center; } -.schema-chat__welcome-subtitle { +.schema-editor__welcome-subtitle { font-size: 14px; color: rgba(0, 0, 0, 0.5); margin: 0; @@ -94,15 +122,15 @@ } @media (prefers-color-scheme: dark) { - .schema-chat__welcome-title { + .schema-editor__welcome-title { color: rgba(255, 255, 255, 0.7); } - .schema-chat__welcome-subtitle { + .schema-editor__welcome-subtitle { color: rgba(255, 255, 255, 0.5); } } -.schema-chat__suggestions { +.schema-editor__suggestions { display: flex; flex-wrap: wrap; justify-content: center; @@ -136,55 +164,116 @@ } } +/* ── Chat panel (right) ── */ + +.schema-editor__panel { + width: 400px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + background: transparent; + min-height: 0; + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__panel { + border-color: rgba(255, 255, 255, 0.1); + } +} + +.schema-editor__panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-weight: 600; + font-size: 14px; + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__panel-header { + border-color: rgba(255, 255, 255, 0.08); + } +} + +.schema-editor__panel-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #6384ff; +} + +.schema-editor__panel-title { + font-size: 14px; + font-weight: 600; +} + /* Messages */ -.schema-chat__messages { +.schema-editor__messages { flex: 1; overflow-y: auto; - width: 100%; - max-width: 700px; - padding: 24px 16px; + padding: 14px 16px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; + min-height: 0; } -.schema-chat__message { +.schema-editor__messages-empty { + font-size: 13px; + color: rgba(0, 0, 0, 0.5); + margin: 8px 0 0; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__messages-empty { + color: rgba(255, 255, 255, 0.5); + } +} + +.schema-editor__message { border-radius: 8px; - padding: 12px 16px; - max-width: 90%; + padding: 10px 14px; + max-width: 95%; line-height: 1.5; white-space: pre-wrap; + font-size: 13px; } -.schema-chat__message--user { +.schema-editor__message--user { align-self: flex-end; background-color: #f0f4f8; } @media (prefers-color-scheme: dark) { - .schema-chat__message--user { + .schema-editor__message--user { background-color: #2d3748; } } -.schema-chat__message--ai { +.schema-editor__message--ai { align-self: flex-start; - background-color: rgba(99, 132, 255, 0.06); + background-color: rgba(99, 132, 255, 0.08); max-width: 100%; } @media (prefers-color-scheme: dark) { - .schema-chat__message--ai { - background-color: rgba(99, 132, 255, 0.1); + .schema-editor__message--ai { + background-color: rgba(99, 132, 255, 0.12); } } -.schema-chat__message-text { +.schema-editor__message-text { white-space: pre-wrap; } -.schema-chat__message--error { +.schema-editor__message--error { align-self: flex-start; display: flex; align-items: flex-start; @@ -193,78 +282,78 @@ } @media (prefers-color-scheme: light) { - .schema-chat__message--error { + .schema-editor__message--error { background-color: var(--color-warnPalette-100); color: var(--color-warnPalette-100-contrast); } } @media (prefers-color-scheme: dark) { - .schema-chat__message--error { + .schema-editor__message--error { background-color: var(--color-warnDarkPalette-200); color: var(--color-warnDarkPalette-200-contrast); } } -.schema-chat__error-icon { - font-size: 20px; - height: 20px; - width: 20px; +.schema-editor__error-icon { + font-size: 18px; + height: 18px; + width: 18px; flex-shrink: 0; margin-top: 2px; } /* Changes */ -.schema-chat__changes { +.schema-editor__changes { display: flex; flex-direction: column; - gap: 10px; - margin-top: 12px; + gap: 8px; + margin-top: 10px; } -.schema-chat__change-card { - border: 1px solid rgba(0, 0, 0, 0.12); +.schema-editor__change-card { + border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 6px; - padding: 10px 12px; + padding: 8px 10px; background: rgba(0, 0, 0, 0.02); } @media (prefers-color-scheme: dark) { - .schema-chat__change-card { + .schema-editor__change-card { border-color: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.04); } } -.schema-chat__change-header { +.schema-editor__change-header { display: flex; align-items: center; gap: 8px; - margin-bottom: 8px; + margin-bottom: 6px; } -.schema-chat__change-type { - font-size: 10px; +.schema-editor__change-type { + font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; - padding: 2px 6px; + padding: 2px 5px; border-radius: 3px; background: var(--color-primaryPalette-500); color: white; } -.schema-chat__change-table { +.schema-editor__change-table { font-weight: 500; - font-size: 13px; + font-size: 12px; } -.schema-chat__change-sql { +.schema-editor__change-sql { background: rgba(0, 0, 0, 0.04); border-radius: 4px; - padding: 10px 12px; - font-size: 12px; + padding: 8px 10px; + font-size: 11px; font-family: monospace; overflow-x: auto; white-space: pre-wrap; @@ -272,19 +361,20 @@ } @media (prefers-color-scheme: dark) { - .schema-chat__change-sql { + .schema-editor__change-sql { background: rgba(255, 255, 255, 0.06); } } -.schema-chat__actions { +/* Actions */ + +.schema-editor__actions { display: flex; justify-content: flex-end; - gap: 10px; - margin-top: 16px; + gap: 8px; } -.schema-chat__actions button mat-icon { +.schema-editor__actions button mat-icon { margin-right: 4px; margin-bottom: -4px; font-size: 18px; @@ -294,25 +384,25 @@ /* Loading */ -.schema-chat__loading { +.schema-editor__loading { display: flex; align-items: center; gap: 8px; - padding: 8px 0; + padding: 6px 0; } -.schema-chat__loading-text { +.schema-editor__loading-text { font-size: 13px; color: #9ca3af; } -.schema-chat__loading-dots { +.schema-editor__loading-dots { display: flex; gap: 6px; align-items: center; } -.schema-chat__loading-dot { +.schema-editor__loading-dot { width: 6px; height: 6px; border-radius: 50%; @@ -320,89 +410,32 @@ animation: loading-bounce 1.4s ease-in-out infinite; } -.schema-chat__loading-dot:nth-child(1) { animation-delay: 0s; } -.schema-chat__loading-dot:nth-child(2) { animation-delay: 0.15s; } -.schema-chat__loading-dot:nth-child(3) { animation-delay: 0.3s; } +.schema-editor__loading-dot:nth-child(1) { animation-delay: 0s; } +.schema-editor__loading-dot:nth-child(2) { animation-delay: 0.15s; } +.schema-editor__loading-dot:nth-child(3) { animation-delay: 0.3s; } @keyframes loading-bounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } } -/* Diagram */ +/* Open tables CTA */ -.schema-chat__diagram-loading { +.schema-editor__open-tables { display: flex; - align-items: center; justify-content: center; - gap: 8px; - padding: 16px 0; -} - -.schema-chat__diagram { - width: 100%; - padding: 0 0 12px; -} - -.schema-chat__diagram-header { - display: flex; - align-items: center; - justify-content: space-between; - margin: 16px 0 8px; -} - -.schema-chat__diagram-title { - font-size: 16px; - font-weight: 600; - margin: 0; -} - -.schema-chat__diagram-zoom { - display: flex; - align-items: center; - gap: 2px; -} - -.schema-chat__diagram-zoom-label { - font-size: 12px; - min-width: 40px; - text-align: center; - color: rgba(0, 0, 0, 0.6); -} - -@media (prefers-color-scheme: dark) { - .schema-chat__diagram-zoom-label { - color: rgba(255, 255, 255, 0.6); - } -} - -.schema-chat__diagram-content { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - padding: 16px; - overflow: auto; - background: rgba(0, 0, 0, 0.02); - max-height: 400px; -} - -.schema-chat__diagram-inner { - transition: transform 0.2s ease; + padding: 8px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + flex-shrink: 0; } @media (prefers-color-scheme: dark) { - .schema-chat__diagram-content { - border-color: rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); + .schema-editor__open-tables { + border-color: rgba(255, 255, 255, 0.08); } } -.schema-chat__open-tables { - display: flex; - justify-content: center; - padding: 8px 16px 32px; -} - -.schema-chat__open-tables button mat-icon { +.schema-editor__open-tables button mat-icon { margin-right: 4px; margin-bottom: -4px; font-size: 18px; @@ -412,49 +445,66 @@ /* Input form */ -.schema-chat__form { - width: 100%; - max-width: 700px; - padding: 0 16px 32px; +.schema-editor__form { + padding: 12px 16px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.06); flex-shrink: 0; } -.schema-chat__input-field { +@media (prefers-color-scheme: dark) { + .schema-editor__form { + border-color: rgba(255, 255, 255, 0.08); + } +} + +.schema-editor__input-field { width: 100%; } -.schema-chat__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading { +.schema-editor__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading { border-radius: 24px 0 0 24px; width: 24px; } -.schema-chat__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { +.schema-editor__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { border-radius: 0 24px 24px 0; } -.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, -.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { +.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, +.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { border-color: #6384ff !important; border-width: 2px !important; } -.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { +.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { border-color: #6384ff !important; border-width: 2px 0 !important; } -.schema-chat__input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { +.schema-editor__input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { display: none; } -.schema-chat__input-field ::ng-deep .mat-mdc-text-field-wrapper { +.schema-editor__input-field ::ng-deep .mat-mdc-text-field-wrapper { padding-bottom: 0 !important; } -.schema-chat__input-field textarea { +.schema-editor__input-field textarea { resize: none; } -.schema-chat__send-button { +.schema-editor__send-button { color: #6384ff !important; } + +/* Responsive: stack on narrow screens */ + +@media (max-width: 900px) { + .schema-editor__body { + flex-direction: column; + } + .schema-editor__panel { + width: 100%; + max-height: 50%; + } +} diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html index 0f57b441f..4b64c870b 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html @@ -1,208 +1,198 @@ -
+
@if (isRoutedPage()) { -
- +
+ arrow_back -

Edit Database Schema

+

Edit Database Schema

} @if (showClose) { - } - - @if (messages().length === 0 && !initialDiagramLoading()) { -
- - @if (showClose || isRoutedPage()) { -

Edit database structure with AI

-

Describe the changes you want to make and AI will generate the SQL for you.

+ +
+ +
+ @if (currentDiagram(); as diagram) { + + } @else if (initialDiagramLoading()) { +
+ Loading diagram +
+ + + +
+
} @else { -

No tables found — let AI create them

-

Describe the database you need and AI will generate the schema for you.

- } +
+ + @if (showClose || isRoutedPage()) { +

Edit database structure with AI

+

Describe the changes you want to make and AI will generate the SQL for you.

+ } @else { +

No tables found — let AI create them

+

Describe the database you need and AI will generate the schema for you.

+ } -
- @if (showClose || isRoutedPage()) { - - - - } @else { - - - - } -
-
- } +
+ @if (showClose || isRoutedPage()) { + + + + } @else { + + + + } +
+
+ } + - - @if (messages().length === 0 && initialDiagramLoading()) { -
- Loading diagram -
- - - + +
- } - - @if (messages().length) { -
- @for (message of messages(); track $index) { - @if (message.role === 'user') { -
- {{ message.text }} -
+
+ @if (chatMessages().length === 0 && !submitting()) { +

Describe what you need and AI will generate the SQL.

} - @if (message.role === 'ai') { -
- {{ message.text }} - @if (message.changes?.length) { -
- @for (change of message.changes; track change.id) { -
-
- {{ change.changeType }} - {{ change.targetTableName }} + @for (message of chatMessages(); track $index) { + @if (message.role === 'user') { +
+ {{ message.text }} +
+ } + @if (message.role === 'ai') { +
+ {{ message.text }} + + @if (message.changes?.length) { +
+ @for (change of message.changes; track change.id) { +
+
+ {{ change.changeType }} + {{ change.targetTableName }} +
+
{{ change.forwardSql }}
-
{{ change.forwardSql }}
-
- } -
- } -
- } - @if (message.role === 'diagram') { -
-
-

{{ message.text }}

-
- - {{ (diagramZoom() * 100).toFixed(0) }}% - - -
+ } +
+ }
-
-
- -
+ } + @if (message.role === 'error') { +
+ error_outline + {{ message.text }} +
+ } + } + + @if (submitting()) { +
+ Generating schema +
+ + +
} - @if (message.role === 'error') { -
- error_outline - {{ message.text }} + @if (initialDiagramLoading() && chatMessages().length > 0) { +
+ Refreshing diagram +
+ + + +
} - } +
- @if (submitting()) { -
- Generating schema -
- - - -
-
- } - @if (initialDiagramLoading() && messages().length > 0) { -
- Loading diagram -
- - - -
+ @if (applied() && !initialDiagramLoading()) { + } -
- } - - @if (applied() && !initialDiagramLoading()) { - - } - -
- @if (pendingBatch()) { -
- - +
+ @if (pendingBatch()) { +
+ + +
+ } @else { +
+ + {{showClose || isRoutedPage() ? 'Describe the changes you need...' : 'Describe the database you need...'}} + + + +
+ }
- } @else { -
- - {{showClose || isRoutedPage() ? 'Describe the changes you need...' : 'Describe the database you need...'}} - - - -
- } +
diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts index 56ec6c60e..9076898bd 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts @@ -6,8 +6,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { MarkdownModule } from 'ngx-markdown'; import { TableSchemaService, SchemaChangeResponse } from 'src/app/services/table-schema.service'; +import { SchemaDiagramViewerComponent } from './schema-diagram-viewer/schema-diagram-viewer.component'; interface ChatMessage { role: 'user' | 'ai' | 'error' | 'diagram'; @@ -24,12 +24,12 @@ interface ChatMessage { imports: [ CommonModule, FormsModule, - MarkdownModule, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, RouterModule, + SchemaDiagramViewerComponent, ], }) export class EditDatabaseSchemaComponent implements OnInit { @@ -48,7 +48,6 @@ export class EditDatabaseSchemaComponent implements OnInit { protected submitting = signal(false); protected applying = signal(false); protected applied = signal(false); - protected diagramZoom = signal(1); protected initialDiagramLoading = signal(false); private _threadId: string | undefined; @@ -60,6 +59,18 @@ export class EditDatabaseSchemaComponent implements OnInit { return null; }); + protected currentDiagram = computed(() => { + const msgs = this.messages(); + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'diagram' && msgs[i].diagramSource) return msgs[i]; + } + return null; + }); + + protected chatMessages = computed(() => + this.messages().filter(m => m.role !== 'diagram'), + ); + ngOnInit(): void { if (!this.connectionID) { const id = this._route.snapshot.paramMap.get('connection-id'); @@ -171,18 +182,6 @@ export class EditDatabaseSchemaComponent implements OnInit { this.schemaApplied.emit(); } - onZoomIn() { - this.diagramZoom.update(z => Math.min(z + 0.25, 3)); - } - - onZoomOut() { - this.diagramZoom.update(z => Math.max(z - 0.25, 0.25)); - } - - onZoomReset() { - this.diagramZoom.set(1); - } - onKeydown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css new file mode 100644 index 000000000..a769bc706 --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css @@ -0,0 +1,333 @@ +:host { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.schema-diagram { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + background: rgba(0, 0, 0, 0.02); + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + } +} + +/* Header / toolbar */ + +.schema-diagram__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + background: transparent; + flex-wrap: wrap; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__header { + border-color: rgba(255, 255, 255, 0.08); + } +} + +.schema-diagram__title { + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.schema-diagram__toolbar { + display: flex; + align-items: center; + gap: 2px; +} + +.schema-diagram__toolbar-button--active { + background-color: rgba(99, 132, 255, 0.16); +} + +.schema-diagram__toolbar-divider { + width: 1px; + height: 20px; + background: rgba(0, 0, 0, 0.12); + margin: 0 6px; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__toolbar-divider { + background: rgba(255, 255, 255, 0.16); + } +} + +.schema-diagram__zoom-label { + font-size: 12px; + min-width: 42px; + text-align: center; + color: rgba(0, 0, 0, 0.6); + user-select: none; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__zoom-label { + color: rgba(255, 255, 255, 0.7); + } +} + +/* Body */ + +.schema-diagram__body { + flex: 1; + display: flex; + min-height: 0; +} + +/* Sidebar */ + +.schema-diagram__sidebar { + position: absolute; + top: 12px; + left: 12px; + z-index: 5; + width: 220px; + display: flex; + flex-direction: column; + max-height: calc(100% - 24px); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(20, 20, 28, 0.82); + } +} + +.schema-diagram__sidebar-search { + position: relative; + display: flex; + align-items: center; + padding: 10px 10px 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar-search { + border-color: rgba(255, 255, 255, 0.08); + } +} + +.schema-diagram__sidebar-search-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.4); + position: absolute; + left: 18px; + pointer-events: none; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar-search-icon { + color: rgba(255, 255, 255, 0.5); + } +} + +.schema-diagram__sidebar-input { + flex: 1; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 999px; + padding: 6px 10px 6px 30px; + font-size: 13px; + background: transparent; + color: inherit; + outline: none; + width: 100%; +} + +.schema-diagram__sidebar-input:focus { + border-color: #6384ff; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar-input { + border-color: rgba(255, 255, 255, 0.16); + } +} + +.schema-diagram__sidebar-list { + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.schema-diagram__sidebar-item { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + width: 100%; + text-align: left; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: inherit; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.schema-diagram__sidebar-item:hover { + background-color: rgba(99, 132, 255, 0.12); +} + +.schema-diagram__sidebar-item-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(0, 0, 0, 0.45); + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar-item-icon { + color: rgba(255, 255, 255, 0.55); + } +} + +.schema-diagram__sidebar-empty { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + margin: 12px; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__sidebar-empty { + color: rgba(255, 255, 255, 0.45); + } +} + +/* Viewport */ + +.schema-diagram__viewport { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background-image: + radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px); + background-size: 18px 18px; +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__viewport { + background-image: + radial-gradient(circle, rgba(255, 255, 255, 0.08) 1px, transparent 1px); + } +} + +.schema-diagram--panning .schema-diagram__viewport { + cursor: grabbing; +} + +.schema-diagram__canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; + line-height: 0; +} + +.schema-diagram__canvas ::ng-deep markdown, +.schema-diagram__canvas ::ng-deep markdown > * { + display: block; + margin: 0 !important; + padding: 0 !important; + line-height: 0; +} + +.schema-diagram__canvas ::ng-deep svg { + max-width: none !important; + max-height: none !important; + display: block; + margin: 0 !important; +} + +.schema-diagram__canvas ::ng-deep .schema-diagram__highlight { + animation: highlight-pulse 1.6s ease; +} + +@keyframes highlight-pulse { + 0% { filter: drop-shadow(0 0 0 rgba(99, 132, 255, 0)); } + 30% { filter: drop-shadow(0 0 14px rgba(99, 132, 255, 0.8)); } + 100% { filter: drop-shadow(0 0 0 rgba(99, 132, 255, 0)); } +} + +/* Minimap */ + +.schema-diagram__minimap { + position: absolute; + right: 12px; + bottom: 12px; + width: 200px; + height: 140px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +@media (prefers-color-scheme: dark) { + .schema-diagram__minimap { + background: rgba(20, 20, 28, 0.92); + border-color: rgba(255, 255, 255, 0.16); + } +} + +.schema-diagram__minimap-canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + pointer-events: none; + line-height: 0; +} + +.schema-diagram__minimap-canvas svg { + max-width: none !important; + max-height: none !important; + display: block; + margin: 0; +} + +.schema-diagram__minimap-viewport { + position: absolute; + border: 2px solid #6384ff; + background: rgba(99, 132, 255, 0.12); + border-radius: 2px; + pointer-events: none; + box-sizing: border-box; +} diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html new file mode 100644 index 000000000..1c5494521 --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html @@ -0,0 +1,79 @@ +
+
+

{{ title }}

+
+ + + + + {{ scaleLabel() }} + + + +
+
+ +
+
+
+ +
+ + @if (sidebarOpen()) { + + } + + @if (minimapOpen()) { +
+
+
+
+ } +
+
+
diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts new file mode 100644 index 000000000..2a640af57 --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts @@ -0,0 +1,441 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, + computed, + effect, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MarkdownModule } from 'ngx-markdown'; + +const MIN_SCALE = 0.1; +const MAX_SCALE = 4; +const ZOOM_STEP = 0.25; + +@Component({ + selector: 'app-schema-diagram-viewer', + templateUrl: './schema-diagram-viewer.component.html', + styleUrls: ['./schema-diagram-viewer.component.css'], + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTooltipModule, + MarkdownModule, + ], +}) +export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { + @Input() title: string = ''; + @Input() set source(value: string) { + const next = value ?? ''; + if (this._source() === next) return; + this._source.set(next); + this._cachedSvg = null; + this.svgReady.set(false); + } + get source(): string { + return this._source(); + } + + @ViewChild('viewport') viewportRef!: ElementRef; + @ViewChild('canvas') canvasRef!: ElementRef; + @ViewChild('minimap') minimapRef!: ElementRef; + @ViewChild('minimapCanvas') minimapCanvasRef!: ElementRef; + + protected _source = signal(''); + protected scale = signal(1); + protected translateX = signal(0); + protected translateY = signal(0); + protected isPanning = signal(false); + protected tableSearch = signal(''); + protected sidebarOpen = signal(true); + protected minimapOpen = signal(true); + protected svgReady = signal(false); + protected svgSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); + protected viewportSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); + protected minimapSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); + + protected tables = computed(() => { + const src = this._source() + .replace(/^```mermaid\n?/i, '') + .replace(/```\s*$/i, ''); + const names: string[] = []; + const seen = new Set(); + for (const rawLine of src.split('\n')) { + const m = rawLine.match(/^\s*([A-Za-z_][\w-]*)\s*\{/); + if (m && !seen.has(m[1])) { + seen.add(m[1]); + names.push(m[1]); + } + } + return names.sort((a, b) => a.localeCompare(b)); + }); + + protected filteredTables = computed(() => { + const q = this.tableSearch().toLowerCase().trim(); + const list = this.tables(); + return q ? list.filter(t => t.toLowerCase().includes(q)) : list; + }); + + protected transformStyle = computed( + () => `translate(${this.translateX()}px, ${this.translateY()}px) scale(${this.scale()})`, + ); + + protected scaleLabel = computed(() => `${Math.round(this.scale() * 100)}%`); + + protected minimapScale = computed(() => { + const svg = this.svgSize(); + const mm = this.minimapSize(); + if (!svg.w || !svg.h || !mm.w || !mm.h) return 0; + const padding = 8; + return Math.min((mm.w - padding * 2) / svg.w, (mm.h - padding * 2) / svg.h); + }); + + protected minimapCanvasTransform = computed(() => { + const s = this.minimapScale(); + const svg = this.svgSize(); + const mm = this.minimapSize(); + if (!s) return 'translate(0,0) scale(0)'; + const tx = (mm.w - svg.w * s) / 2; + const ty = (mm.h - svg.h * s) / 2; + return `translate(${tx}px, ${ty}px) scale(${s})`; + }); + + protected minimapViewportRect = computed(() => { + const s = this.scale(); + const mm = this.minimapScale(); + const svg = this.svgSize(); + const vp = this.viewportSize(); + if (!s || !mm || !svg.w || !vp.w) { + return { x: 0, y: 0, w: 0, h: 0 }; + } + const offsetX = (this.minimapSize().w - svg.w * mm) / 2; + const offsetY = (this.minimapSize().h - svg.h * mm) / 2; + const x = offsetX + (-this.translateX() / s) * mm; + const y = offsetY + (-this.translateY() / s) * mm; + const w = (vp.w / s) * mm; + const h = (vp.h / s) * mm; + return { x, y, w, h }; + }); + + private _panStart = { x: 0, y: 0, tx: 0, ty: 0 }; + private _renderObserver: MutationObserver | null = null; + private _resizeObserver: ResizeObserver | null = null; + private _cachedSvg: SVGSVGElement | null = null; + private _onPanMove = (e: MouseEvent) => this._handlePanMove(e); + private _onPanEnd = () => this._handlePanEnd(); + private _onMinimapDragMove = (e: MouseEvent) => this._handleMinimapDragMove(e); + private _onMinimapDragEnd = () => this._handleMinimapDragEnd(); + private _minimapDragging = false; + + constructor() { + effect(() => { + if (!this.minimapOpen() || !this._cachedSvg) return; + queueMicrotask(() => { + if (this._cachedSvg) this._renderMinimapClone(this._cachedSvg); + }); + }); + } + + ngAfterViewInit(): void { + this._observeRender(); + this._observeResize(); + } + + ngOnDestroy(): void { + this._renderObserver?.disconnect(); + this._resizeObserver?.disconnect(); + document.removeEventListener('mousemove', this._onPanMove); + document.removeEventListener('mouseup', this._onPanEnd); + document.removeEventListener('mousemove', this._onMinimapDragMove); + document.removeEventListener('mouseup', this._onMinimapDragEnd); + } + + onZoomIn() { + this._zoomAt(this.scale() + ZOOM_STEP); + } + + onZoomOut() { + this._zoomAt(this.scale() - ZOOM_STEP); + } + + onZoomReset() { + this.scale.set(1); + this._centerContent(); + } + + onFitToScreen() { + const vp = this.viewportSize(); + const svg = this.svgSize(); + if (!vp.w || !svg.w) return; + const padding = 24; + const fit = Math.min( + (vp.w - padding * 2) / svg.w, + (vp.h - padding * 2) / svg.h, + ); + const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit)); + this.scale.set(newScale); + this.translateX.set((vp.w - svg.w * newScale) / 2); + this.translateY.set((vp.h - svg.h * newScale) / 2); + } + + onPanStart(event: MouseEvent) { + const target = event.target as HTMLElement; + if (target.closest('button, a, input, .schema-diagram__sidebar, .schema-diagram__minimap, .schema-diagram__toolbar')) { + return; + } + this.isPanning.set(true); + this._panStart = { + x: event.clientX, + y: event.clientY, + tx: this.translateX(), + ty: this.translateY(), + }; + document.addEventListener('mousemove', this._onPanMove); + document.addEventListener('mouseup', this._onPanEnd); + event.preventDefault(); + } + + onWheel(event: WheelEvent) { + event.preventDefault(); + const rect = this.viewportRef.nativeElement.getBoundingClientRect(); + const mx = event.clientX - rect.left; + const my = event.clientY - rect.top; + const delta = -event.deltaY * 0.0025; + const factor = 1 + delta; + this._zoomAt(this.scale() * factor, mx, my); + } + + onDoubleClick(event: MouseEvent) { + const target = event.target as Element | null; + if (!target) return; + const node = this._findEntityNode(target); + if (node) this._focusElement(node); + } + + onFocusTable(name: string) { + const node = this._findNodeByName(name); + if (node) this._focusElement(node); + } + + onMinimapMouseDown(event: MouseEvent) { + this._minimapDragging = true; + this._centerMinimapAt(event); + document.addEventListener('mousemove', this._onMinimapDragMove); + document.addEventListener('mouseup', this._onMinimapDragEnd); + event.preventDefault(); + event.stopPropagation(); + } + + onToggleSidebar() { + this.sidebarOpen.update(v => !v); + } + + onToggleMinimap() { + this.minimapOpen.update(v => !v); + } + + private _handlePanMove(event: MouseEvent) { + const dx = event.clientX - this._panStart.x; + const dy = event.clientY - this._panStart.y; + this.translateX.set(this._panStart.tx + dx); + this.translateY.set(this._panStart.ty + dy); + } + + private _handlePanEnd() { + this.isPanning.set(false); + document.removeEventListener('mousemove', this._onPanMove); + document.removeEventListener('mouseup', this._onPanEnd); + } + + private _handleMinimapDragMove(event: MouseEvent) { + if (!this._minimapDragging) return; + this._centerMinimapAt(event); + } + + private _handleMinimapDragEnd() { + this._minimapDragging = false; + document.removeEventListener('mousemove', this._onMinimapDragMove); + document.removeEventListener('mouseup', this._onMinimapDragEnd); + } + + private _centerMinimapAt(event: MouseEvent) { + const minimap = this.minimapRef?.nativeElement; + if (!minimap) return; + const rect = minimap.getBoundingClientRect(); + const mm = this.minimapScale(); + if (!mm) return; + const svg = this.svgSize(); + const offsetX = (rect.width - svg.w * mm) / 2; + const offsetY = (rect.height - svg.h * mm) / 2; + const cx = (event.clientX - rect.left - offsetX) / mm; + const cy = (event.clientY - rect.top - offsetY) / mm; + const vp = this.viewportSize(); + const s = this.scale(); + this.translateX.set(vp.w / 2 - cx * s); + this.translateY.set(vp.h / 2 - cy * s); + } + + private _zoomAt(targetScale: number, originX?: number, originY?: number) { + const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale)); + const oldScale = this.scale(); + if (clamped === oldScale) return; + const vp = this.viewportSize(); + const ox = originX ?? vp.w / 2; + const oy = originY ?? vp.h / 2; + const ratio = clamped / oldScale; + this.translateX.update(tx => ox - (ox - tx) * ratio); + this.translateY.update(ty => oy - (oy - ty) * ratio); + this.scale.set(clamped); + } + + private _centerContent() { + const vp = this.viewportSize(); + const svg = this.svgSize(); + const s = this.scale(); + if (!vp.w || !svg.w) return; + this.translateX.set((vp.w - svg.w * s) / 2); + this.translateY.set((vp.h - svg.h * s) / 2); + } + + private _observeRender() { + const canvas = this.canvasRef?.nativeElement; + if (!canvas) return; + const tryAttach = () => { + const svg = canvas.querySelector('svg') as SVGSVGElement | null; + if (!svg) return; + if (svg === this._cachedSvg) return; + this._cachedSvg = svg; + this._prepareSvg(svg); + this._renderMinimapClone(svg); + this.svgReady.set(true); + requestAnimationFrame(() => this.onFitToScreen()); + }; + tryAttach(); + this._renderObserver?.disconnect(); + this._renderObserver = new MutationObserver(() => tryAttach()); + this._renderObserver.observe(canvas, { childList: true, subtree: true }); + } + + private _observeResize() { + const viewport = this.viewportRef?.nativeElement; + const minimap = this.minimapRef?.nativeElement; + if (!viewport) return; + this._resizeObserver = new ResizeObserver(() => { + const vpRect = viewport.getBoundingClientRect(); + this.viewportSize.set({ w: vpRect.width, h: vpRect.height }); + if (minimap) { + const mmRect = minimap.getBoundingClientRect(); + this.minimapSize.set({ w: mmRect.width, h: mmRect.height }); + } + }); + this._resizeObserver.observe(viewport); + if (minimap) this._resizeObserver.observe(minimap); + } + + private _prepareSvg(svg: SVGSVGElement) { + svg.removeAttribute('style'); + svg.style.display = 'block'; + svg.style.maxWidth = 'none'; + svg.style.maxHeight = 'none'; + svg.style.height = 'auto'; + svg.style.margin = '0'; + + const measure = () => { + const rect = svg.getBoundingClientRect(); + const currentScale = this.scale() || 1; + let width = rect.width / currentScale; + let height = rect.height / currentScale; + if (!width || !height) { + const bbox = svg.getBBox(); + width = width || bbox.width || 800; + height = height || bbox.height || 600; + } + this.svgSize.set({ w: width, h: height }); + this._renderMinimapClone(svg); + }; + measure(); + requestAnimationFrame(measure); + } + + private _renderMinimapClone(svg: SVGSVGElement) { + const host = this.minimapCanvasRef?.nativeElement; + if (!host) return; + const { w, h } = this.svgSize(); + if (!w || !h) return; + host.innerHTML = ''; + const clone = svg.cloneNode(true) as SVGSVGElement; + clone.removeAttribute('id'); + clone.removeAttribute('style'); + clone.style.display = 'block'; + clone.style.margin = '0'; + clone.style.pointerEvents = 'none'; + clone.setAttribute('width', String(w)); + clone.setAttribute('height', String(h)); + host.appendChild(clone); + } + + private _findEntityNode(target: Element): SVGGraphicsElement | null { + const selectors = [ + 'g.node', + 'g.er', + 'g[id^="entity-"]', + 'g.entity', + 'g.classGroup', + ]; + for (const sel of selectors) { + const match = target.closest(sel); + if (match) return match as SVGGraphicsElement; + } + return null; + } + + private _findNodeByName(name: string): SVGGraphicsElement | null { + const svg = this.canvasRef?.nativeElement?.querySelector('svg'); + if (!svg) return null; + const target = name.toLowerCase(); + const labels = svg.querySelectorAll('text, .er .entityLabel, .nodeLabel'); + let best: SVGGraphicsElement | null = null; + labels.forEach(label => { + if (best) return; + const text = (label.textContent ?? '').trim().toLowerCase(); + if (text === target) { + const node = (label.closest('g[id], g.node, g.er, g.entity') || + label.parentElement) as SVGGraphicsElement | null; + if (node) best = node; + } + }); + return best; + } + + private _focusElement(node: SVGGraphicsElement) { + const viewport = this.viewportRef?.nativeElement; + if (!viewport) return; + const vpRect = viewport.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + const targetScale = Math.max(this.scale(), 1.2); + const ratio = targetScale / this.scale(); + const nodeCenterX = nodeRect.left - vpRect.left + nodeRect.width / 2; + const nodeCenterY = nodeRect.top - vpRect.top + nodeRect.height / 2; + const newCenterX = nodeCenterX * ratio + (this.translateX() * (ratio - 1)); + const newCenterY = nodeCenterY * ratio + (this.translateY() * (ratio - 1)); + this.scale.set(targetScale); + this.translateX.update(tx => tx + (vpRect.width / 2 - newCenterX)); + this.translateY.update(ty => ty + (vpRect.height / 2 - newCenterY)); + node.classList.add('schema-diagram__highlight'); + setTimeout(() => node.classList.remove('schema-diagram__highlight'), 1600); + } +} From b0c17352333ee65eaeabfea72d25c32a527c1668 Mon Sep 17 00:00:00 2001 From: Karina Kharchenko Date: Mon, 18 May 2026 18:23:11 +0300 Subject: [PATCH 2/9] feat(edit-schema): preview diagram, autosize prompt, robust fit-to-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate a mermaid ER preview from AI-returned CREATE TABLE SQL and show it on the canvas as "Schema Preview" before the user applies — useful for empty databases where the backend has no schema yet. Switch the prompt textarea to cdkTextareaAutosize so it grows as the user types (2–10 rows). Make the diagram viewer's fit-to-screen scale up to fill the visible area (previously capped at 1×), measure the viewport live if ResizeObserver hasn't fired yet, and reset transform + size signals when the source changes so a new diagram is centered and sized from a clean state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../edit-database-schema.component.html | 4 +- .../edit-database-schema.component.ts | 95 +++++++++++++++++-- .../schema-diagram-viewer.component.ts | 14 ++- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html index 4b64c870b..d2043e7be 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html @@ -179,7 +179,9 @@

Edit Database Schema

[ngModel]="userPrompt()" (ngModelChange)="userPrompt.set($event)" (keydown)="onKeydown($event)" - rows="2" + cdkTextareaAutosize + cdkAutosizeMinRows="2" + cdkAutosizeMaxRows="10" maxlength="1000" [disabled]="submitting()" data-testid="schema-prompt-textarea" diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts index 9076898bd..1d6df030e 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts @@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { TextFieldModule } from '@angular/cdk/text-field'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { TableSchemaService, SchemaChangeResponse } from 'src/app/services/table-schema.service'; import { SchemaDiagramViewerComponent } from './schema-diagram-viewer/schema-diagram-viewer.component'; @@ -28,6 +29,7 @@ interface ChatMessage { MatIconModule, MatFormFieldModule, MatInputModule, + TextFieldModule, RouterModule, SchemaDiagramViewerComponent, ], @@ -105,12 +107,23 @@ export class EditDatabaseSchemaComponent implements OnInit { if (result && result.changes.length > 0) { const summary = result.changes.map(c => `**${c.changeType}** \`${c.targetTableName}\`${c.aiSummary ? ' — ' + c.aiSummary : ''}`).join('\n'); this.applied.set(false); - this.messages.update(msgs => [...msgs, { - role: 'ai', - text: `I've generated ${result.changes.length} change(s) for your database:\n\n${summary}\n\nReview the SQL below and approve or reject.`, - changes: result.changes, - batchId: result.batchId, - }]); + const previewSource = this._buildMermaidFromChanges(result.changes); + this.messages.update(msgs => { + const next: ChatMessage[] = [...msgs, { + role: 'ai', + text: `I've generated ${result.changes.length} change(s) for your database:\n\n${summary}\n\nReview the SQL below and approve or reject.`, + changes: result.changes, + batchId: result.batchId, + }]; + if (previewSource) { + next.push({ + role: 'diagram', + text: 'Schema Preview', + diagramSource: '```mermaid\n' + previewSource + '\n```', + }); + } + return next; + }); } else { this.messages.update(msgs => [...msgs, { role: 'ai', @@ -206,4 +219,74 @@ export class EditDatabaseSchemaComponent implements OnInit { this.initialDiagramLoading.set(false); } } + + private _buildMermaidFromChanges(changes: SchemaChangeResponse[]): string { + const tables: { name: string; columns: { type: string; name: string; pk: boolean; fk: boolean }[] }[] = []; + const relations: { from: string; to: string }[] = []; + const isCreate = /^\s*CREATE\s+TABLE/i; + + for (const change of changes) { + const sql = change.forwardSql ?? ''; + if (!isCreate.test(sql)) continue; + const tableMatch = sql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s*\(([\s\S]*)\)\s*;?\s*$/i); + if (!tableMatch) continue; + const tableName = tableMatch[1]; + const body = tableMatch[2]; + + const parts: string[] = []; + let depth = 0; + let buf = ''; + for (const ch of body) { + if (ch === '(') depth++; + else if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf.trim()); + + const columns: { type: string; name: string; pk: boolean; fk: boolean }[] = []; + for (const raw of parts) { + const part = raw.trim(); + const tableConstraint = part.match(/^(?:PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|INDEX|KEY)\b/i); + if (tableConstraint) { + const refMatch = part.match(/REFERENCES\s+["`]?(\w+)["`]?/i); + if (refMatch) relations.push({ from: tableName, to: refMatch[1] }); + continue; + } + const colMatch = part.match(/^["`]?(\w+)["`]?\s+([A-Za-z][\w]*)/); + if (!colMatch) continue; + const colName = colMatch[1]; + const colType = colMatch[2].toLowerCase(); + const pk = /PRIMARY\s+KEY/i.test(part); + const inlineRef = part.match(/REFERENCES\s+["`]?(\w+)["`]?/i); + const fk = !!inlineRef; + if (inlineRef) relations.push({ from: tableName, to: inlineRef[1] }); + columns.push({ name: colName, type: colType, pk, fk }); + } + + if (columns.length > 0) tables.push({ name: tableName, columns }); + } + + if (tables.length === 0) return ''; + + let out = 'erDiagram\n'; + for (const rel of relations) { + if (tables.some(t => t.name === rel.to)) { + out += ` ${rel.to} ||--o{ ${rel.from} : has\n`; + } + } + for (const t of tables) { + out += ` ${t.name} {\n`; + for (const col of t.columns) { + const tag = col.pk ? ' PK' : col.fk ? ' FK' : ''; + out += ` ${col.type || 'string'} ${col.name}${tag}\n`; + } + out += ` }\n`; + } + return out; + } } diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts index 2a640af57..ee1021a29 100644 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts @@ -45,6 +45,10 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { this._source.set(next); this._cachedSvg = null; this.svgReady.set(false); + this.svgSize.set({ w: 0, h: 0 }); + this.scale.set(1); + this.translateX.set(0); + this.translateY.set(0); } get source(): string { return this._source(); @@ -178,7 +182,14 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { } onFitToScreen() { - const vp = this.viewportSize(); + let vp = this.viewportSize(); + if (!vp.w || !vp.h) { + const rect = this.viewportRef?.nativeElement?.getBoundingClientRect(); + if (rect && rect.width && rect.height) { + vp = { w: rect.width, h: rect.height }; + this.viewportSize.set(vp); + } + } const svg = this.svgSize(); if (!vp.w || !svg.w) return; const padding = 24; @@ -323,6 +334,7 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { this._renderMinimapClone(svg); this.svgReady.set(true); requestAnimationFrame(() => this.onFitToScreen()); + setTimeout(() => this.onFitToScreen(), 80); }; tryAttach(); this._renderObserver?.disconnect(); From a45b454a09369bb2b73abc2190e600f6f2787e3c Mon Sep 17 00:00:00 2001 From: Karina Kharchenko Date: Tue, 19 May 2026 11:41:41 +0300 Subject: [PATCH 3/9] refactor(schema-viewer): drop minimap, anchor 100% zoom to fit-to-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the corner minimap (clone, viewport rectangle, drag handlers, toggle, and resize tracking). Introduce a baseScale signal that holds the fit-to-screen scale, and display zoom as a percentage relative to it so 100% = filling the visible area. Zoom in/out steps and clamps are expressed relative to baseScale (20%–800% of fit), so users always have the same headroom to zoom in regardless of how big or small the diagram is. Reset view now re-runs fit-to-screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../schema-diagram-viewer.component.css | 47 ------ .../schema-diagram-viewer.component.html | 17 -- .../schema-diagram-viewer.component.ts | 157 +++--------------- 3 files changed, 19 insertions(+), 202 deletions(-) diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css index a769bc706..937819576 100644 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css @@ -284,50 +284,3 @@ 100% { filter: drop-shadow(0 0 0 rgba(99, 132, 255, 0)); } } -/* Minimap */ - -.schema-diagram__minimap { - position: absolute; - right: 12px; - bottom: 12px; - width: 200px; - height: 140px; - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - overflow: hidden; - cursor: pointer; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__minimap { - background: rgba(20, 20, 28, 0.92); - border-color: rgba(255, 255, 255, 0.16); - } -} - -.schema-diagram__minimap-canvas { - position: absolute; - top: 0; - left: 0; - transform-origin: 0 0; - pointer-events: none; - line-height: 0; -} - -.schema-diagram__minimap-canvas svg { - max-width: none !important; - max-height: none !important; - display: block; - margin: 0; -} - -.schema-diagram__minimap-viewport { - position: absolute; - border: 2px solid #6384ff; - background: rgba(99, 132, 255, 0.12); - border-radius: 2px; - pointer-events: none; - box-sizing: border-box; -} diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html index 1c5494521..5ab3edaf4 100644 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html @@ -7,11 +7,6 @@

{{ title }}

(click)="onToggleSidebar()"> list -
diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts index ee1021a29..095f9386f 100644 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts @@ -6,7 +6,6 @@ import { OnDestroy, ViewChild, computed, - effect, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -18,8 +17,10 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MarkdownModule } from 'ngx-markdown'; -const MIN_SCALE = 0.1; -const MAX_SCALE = 4; +const MIN_RELATIVE = 0.2; +const MAX_RELATIVE = 8; +const ABSOLUTE_MIN = 0.05; +const ABSOLUTE_MAX = 40; const ZOOM_STEP = 0.25; @Component({ @@ -47,6 +48,7 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { this.svgReady.set(false); this.svgSize.set({ w: 0, h: 0 }); this.scale.set(1); + this.baseScale.set(1); this.translateX.set(0); this.translateY.set(0); } @@ -56,21 +58,18 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { @ViewChild('viewport') viewportRef!: ElementRef; @ViewChild('canvas') canvasRef!: ElementRef; - @ViewChild('minimap') minimapRef!: ElementRef; - @ViewChild('minimapCanvas') minimapCanvasRef!: ElementRef; protected _source = signal(''); protected scale = signal(1); + protected baseScale = signal(1); protected translateX = signal(0); protected translateY = signal(0); protected isPanning = signal(false); protected tableSearch = signal(''); protected sidebarOpen = signal(true); - protected minimapOpen = signal(true); protected svgReady = signal(false); protected svgSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); protected viewportSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); - protected minimapSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); protected tables = computed(() => { const src = this._source() @@ -98,41 +97,9 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { () => `translate(${this.translateX()}px, ${this.translateY()}px) scale(${this.scale()})`, ); - protected scaleLabel = computed(() => `${Math.round(this.scale() * 100)}%`); - - protected minimapScale = computed(() => { - const svg = this.svgSize(); - const mm = this.minimapSize(); - if (!svg.w || !svg.h || !mm.w || !mm.h) return 0; - const padding = 8; - return Math.min((mm.w - padding * 2) / svg.w, (mm.h - padding * 2) / svg.h); - }); - - protected minimapCanvasTransform = computed(() => { - const s = this.minimapScale(); - const svg = this.svgSize(); - const mm = this.minimapSize(); - if (!s) return 'translate(0,0) scale(0)'; - const tx = (mm.w - svg.w * s) / 2; - const ty = (mm.h - svg.h * s) / 2; - return `translate(${tx}px, ${ty}px) scale(${s})`; - }); - - protected minimapViewportRect = computed(() => { - const s = this.scale(); - const mm = this.minimapScale(); - const svg = this.svgSize(); - const vp = this.viewportSize(); - if (!s || !mm || !svg.w || !vp.w) { - return { x: 0, y: 0, w: 0, h: 0 }; - } - const offsetX = (this.minimapSize().w - svg.w * mm) / 2; - const offsetY = (this.minimapSize().h - svg.h * mm) / 2; - const x = offsetX + (-this.translateX() / s) * mm; - const y = offsetY + (-this.translateY() / s) * mm; - const w = (vp.w / s) * mm; - const h = (vp.h / s) * mm; - return { x, y, w, h }; + protected scaleLabel = computed(() => { + const base = this.baseScale() || 1; + return `${Math.round((this.scale() / base) * 100)}%`; }); private _panStart = { x: 0, y: 0, tx: 0, ty: 0 }; @@ -141,18 +108,6 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { private _cachedSvg: SVGSVGElement | null = null; private _onPanMove = (e: MouseEvent) => this._handlePanMove(e); private _onPanEnd = () => this._handlePanEnd(); - private _onMinimapDragMove = (e: MouseEvent) => this._handleMinimapDragMove(e); - private _onMinimapDragEnd = () => this._handleMinimapDragEnd(); - private _minimapDragging = false; - - constructor() { - effect(() => { - if (!this.minimapOpen() || !this._cachedSvg) return; - queueMicrotask(() => { - if (this._cachedSvg) this._renderMinimapClone(this._cachedSvg); - }); - }); - } ngAfterViewInit(): void { this._observeRender(); @@ -164,21 +119,18 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { this._resizeObserver?.disconnect(); document.removeEventListener('mousemove', this._onPanMove); document.removeEventListener('mouseup', this._onPanEnd); - document.removeEventListener('mousemove', this._onMinimapDragMove); - document.removeEventListener('mouseup', this._onMinimapDragEnd); } onZoomIn() { - this._zoomAt(this.scale() + ZOOM_STEP); + this._zoomAt(this.scale() + this.baseScale() * ZOOM_STEP); } onZoomOut() { - this._zoomAt(this.scale() - ZOOM_STEP); + this._zoomAt(this.scale() - this.baseScale() * ZOOM_STEP); } onZoomReset() { - this.scale.set(1); - this._centerContent(); + this.onFitToScreen(); } onFitToScreen() { @@ -197,7 +149,8 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { (vp.w - padding * 2) / svg.w, (vp.h - padding * 2) / svg.h, ); - const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit)); + const newScale = Math.max(ABSOLUTE_MIN, Math.min(ABSOLUTE_MAX, fit)); + this.baseScale.set(newScale); this.scale.set(newScale); this.translateX.set((vp.w - svg.w * newScale) / 2); this.translateY.set((vp.h - svg.h * newScale) / 2); @@ -205,7 +158,7 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { onPanStart(event: MouseEvent) { const target = event.target as HTMLElement; - if (target.closest('button, a, input, .schema-diagram__sidebar, .schema-diagram__minimap, .schema-diagram__toolbar')) { + if (target.closest('button, a, input, .schema-diagram__sidebar, .schema-diagram__toolbar')) { return; } this.isPanning.set(true); @@ -242,23 +195,10 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { if (node) this._focusElement(node); } - onMinimapMouseDown(event: MouseEvent) { - this._minimapDragging = true; - this._centerMinimapAt(event); - document.addEventListener('mousemove', this._onMinimapDragMove); - document.addEventListener('mouseup', this._onMinimapDragEnd); - event.preventDefault(); - event.stopPropagation(); - } - onToggleSidebar() { this.sidebarOpen.update(v => !v); } - onToggleMinimap() { - this.minimapOpen.update(v => !v); - } - private _handlePanMove(event: MouseEvent) { const dx = event.clientX - this._panStart.x; const dy = event.clientY - this._panStart.y; @@ -272,36 +212,11 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { document.removeEventListener('mouseup', this._onPanEnd); } - private _handleMinimapDragMove(event: MouseEvent) { - if (!this._minimapDragging) return; - this._centerMinimapAt(event); - } - - private _handleMinimapDragEnd() { - this._minimapDragging = false; - document.removeEventListener('mousemove', this._onMinimapDragMove); - document.removeEventListener('mouseup', this._onMinimapDragEnd); - } - - private _centerMinimapAt(event: MouseEvent) { - const minimap = this.minimapRef?.nativeElement; - if (!minimap) return; - const rect = minimap.getBoundingClientRect(); - const mm = this.minimapScale(); - if (!mm) return; - const svg = this.svgSize(); - const offsetX = (rect.width - svg.w * mm) / 2; - const offsetY = (rect.height - svg.h * mm) / 2; - const cx = (event.clientX - rect.left - offsetX) / mm; - const cy = (event.clientY - rect.top - offsetY) / mm; - const vp = this.viewportSize(); - const s = this.scale(); - this.translateX.set(vp.w / 2 - cx * s); - this.translateY.set(vp.h / 2 - cy * s); - } - private _zoomAt(targetScale: number, originX?: number, originY?: number) { - const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, targetScale)); + const base = this.baseScale() || 1; + const minAbs = Math.max(ABSOLUTE_MIN, base * MIN_RELATIVE); + const maxAbs = Math.min(ABSOLUTE_MAX, base * MAX_RELATIVE); + const clamped = Math.max(minAbs, Math.min(maxAbs, targetScale)); const oldScale = this.scale(); if (clamped === oldScale) return; const vp = this.viewportSize(); @@ -313,15 +228,6 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { this.scale.set(clamped); } - private _centerContent() { - const vp = this.viewportSize(); - const svg = this.svgSize(); - const s = this.scale(); - if (!vp.w || !svg.w) return; - this.translateX.set((vp.w - svg.w * s) / 2); - this.translateY.set((vp.h - svg.h * s) / 2); - } - private _observeRender() { const canvas = this.canvasRef?.nativeElement; if (!canvas) return; @@ -331,7 +237,6 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { if (svg === this._cachedSvg) return; this._cachedSvg = svg; this._prepareSvg(svg); - this._renderMinimapClone(svg); this.svgReady.set(true); requestAnimationFrame(() => this.onFitToScreen()); setTimeout(() => this.onFitToScreen(), 80); @@ -344,18 +249,12 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { private _observeResize() { const viewport = this.viewportRef?.nativeElement; - const minimap = this.minimapRef?.nativeElement; if (!viewport) return; this._resizeObserver = new ResizeObserver(() => { const vpRect = viewport.getBoundingClientRect(); this.viewportSize.set({ w: vpRect.width, h: vpRect.height }); - if (minimap) { - const mmRect = minimap.getBoundingClientRect(); - this.minimapSize.set({ w: mmRect.width, h: mmRect.height }); - } }); this._resizeObserver.observe(viewport); - if (minimap) this._resizeObserver.observe(minimap); } private _prepareSvg(svg: SVGSVGElement) { @@ -377,29 +276,11 @@ export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { height = height || bbox.height || 600; } this.svgSize.set({ w: width, h: height }); - this._renderMinimapClone(svg); }; measure(); requestAnimationFrame(measure); } - private _renderMinimapClone(svg: SVGSVGElement) { - const host = this.minimapCanvasRef?.nativeElement; - if (!host) return; - const { w, h } = this.svgSize(); - if (!w || !h) return; - host.innerHTML = ''; - const clone = svg.cloneNode(true) as SVGSVGElement; - clone.removeAttribute('id'); - clone.removeAttribute('style'); - clone.style.display = 'block'; - clone.style.margin = '0'; - clone.style.pointerEvents = 'none'; - clone.setAttribute('width', String(w)); - clone.setAttribute('height', String(h)); - host.appendChild(clone); - } - private _findEntityNode(target: Element): SVGGraphicsElement | null { const selectors = [ 'g.node', From 61d38c7f3e5d0270c9e419b28ad18ff245ba85d2 Mon Sep 17 00:00:00 2001 From: Karina Kharchenko Date: Tue, 19 May 2026 12:31:59 +0300 Subject: [PATCH 4/9] feat(edit-schema): better empty state, template cards, AST-based preview Switch the Schema Preview builder from regex/comma-splitting to a real SQL parser via node-sql-parser (PostgresQL with MySQL fallback) so multi-line constraints, quoted identifiers, dialect-specific suffixes, and standalone PRIMARY/FOREIGN KEY declarations are all picked up correctly. Rejecting a batch now also wipes any "Schema Preview" diagram message it produced. Refresh the empty-database welcome screen: replace the rocket with a dashboard-style icon-box around the Material "schema" glyph, retitle to "Generate your first table to start managing data", and show four template cards (E-commerce, Blog, Project management, CRM) listing their tables. In the right-docked Schema Assistant panel, the "Try asking" examples are pinned near the input and only render when the connection has no tables; prompt textarea grows with content via cdkTextareaAutosize. User messages keep a bubble while AI replies blend into the panel background. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../edit-database-schema.component.css | 234 ++++++++++++++++-- .../edit-database-schema.component.html | 109 +++++--- .../edit-database-schema.component.ts | 155 ++++++++---- frontend/yarn.lock | 25 ++ 4 files changed, 420 insertions(+), 103 deletions(-) diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css index dfc74ce9d..7ade26771 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css @@ -95,15 +95,23 @@ overflow-y: auto; } -.schema-editor__welcome-icon { - width: 48px !important; - height: 48px !important; - animation: rocket-float 3s ease-in-out infinite; +.schema-editor__welcome-icon-box { + width: 48px; + height: 48px; + border-radius: 12px; + background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; } -@keyframes rocket-float { - 0%, 100% { transform: translateY(0) rotate(0deg); } - 50% { transform: translateY(-2px) rotate(2deg); } +.schema-editor__welcome-icon { + color: var(--color-accentedPalette-500); + font-size: 26px !important; + width: 26px !important; + height: 26px !important; + transform: rotate(-90deg); } .schema-editor__welcome-title { @@ -130,26 +138,146 @@ } } +.schema-editor__suggestions-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.schema-editor__suggestions-heading { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.6px; + color: rgba(0, 0, 0, 0.42); + margin: 0; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__suggestions-heading { + color: rgba(255, 255, 255, 0.45); + } +} + .schema-editor__suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; - margin-top: 8px; + margin-top: 0; +} + +.schema-editor__templates { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + width: 100%; + max-width: 460px; +} + +.template-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + text-align: left; + background: var(--mat-sidenav-content-background-color); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +} + +.template-card:hover { + border-color: var(--color-accentedPalette-500); + box-shadow: 0 1px 0 0 color-mix(in srgb, var(--color-accentedPalette-500), transparent 80%); +} + +@media (prefers-color-scheme: dark) { + .template-card { + border-color: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.9); + } +} + +.template-card__head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.template-card__icon-box { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); +} + +.template-card__icon { + font-size: 13px !important; + width: 13px !important; + height: 13px !important; + color: var(--color-accentedPalette-500); +} + +.template-card__title { + flex: 1; + font-size: 12.5px; + font-weight: 600; + color: rgba(0, 0, 0, 0.88); + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (prefers-color-scheme: dark) { + .template-card__title { + color: rgba(255, 255, 255, 0.92); + } +} + +.template-card__tables { + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + line-height: 1.35; +} + +@media (prefers-color-scheme: dark) { + .template-card__tables { + color: rgba(255, 255, 255, 0.5); + } } .suggestion-chip { display: inline-flex; align-items: center; + gap: 6px; background-color: var(--mat-sidenav-content-background-color); border: 1px solid #d3d3d3; border-radius: 16px; - padding: 6px 14px; + padding: 6px 14px 6px 10px; font-size: 13px; cursor: pointer; transition: background-color 0.2s ease; } +.suggestion-chip__icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-primaryPalette-500); + flex-shrink: 0; +} + .suggestion-chip:hover { background-color: var(--color-primaryPalette-50); } @@ -226,9 +354,17 @@ } .schema-editor__messages-empty { - font-size: 13px; - color: rgba(0, 0, 0, 0.5); - margin: 8px 0 0; + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + margin: 4px 0 0; + line-height: 1.45; +} + +.schema-editor__try-asking-block { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; } @media (prefers-color-scheme: dark) { @@ -237,6 +373,68 @@ } } +.schema-editor__try-asking-heading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: rgba(0, 0, 0, 0.42); + margin: 4px 0 0; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__try-asking-heading { + color: rgba(255, 255, 255, 0.45); + } +} + +.schema-editor__try-asking { + display: flex; + flex-direction: column; + gap: 6px; +} + +.try-asking-item { + display: flex; + align-items: flex-start; + gap: 8px; + background: none; + border: 1px dashed rgba(0, 0, 0, 0.14); + border-radius: 10px; + padding: 8px 10px; + text-align: left; + font-size: 12.5px; + line-height: 1.4; + color: rgba(0, 0, 0, 0.7); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; +} + +.try-asking-item:hover { + border-color: var(--color-accentedPalette-500); + background-color: color-mix(in srgb, var(--color-accentedPalette-500), transparent 94%); + color: rgba(0, 0, 0, 0.9); +} + +@media (prefers-color-scheme: dark) { + .try-asking-item { + border-color: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.7); + } + .try-asking-item:hover { + color: rgba(255, 255, 255, 0.95); + } +} + +.try-asking-item__icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + margin-top: 1px; + color: var(--color-accentedPalette-500); + flex-shrink: 0; +} + .schema-editor__message { border-radius: 8px; padding: 10px 14px; @@ -258,17 +456,13 @@ } .schema-editor__message--ai { - align-self: flex-start; - background-color: rgba(99, 132, 255, 0.08); + align-self: stretch; + background-color: transparent; + padding-left: 0; + padding-right: 0; max-width: 100%; } -@media (prefers-color-scheme: dark) { - .schema-editor__message--ai { - background-color: rgba(99, 132, 255, 0.12); - } -} - .schema-editor__message-text { white-space: pre-wrap; } diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html index d2043e7be..7cfac7172 100644 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html +++ b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html @@ -32,45 +32,57 @@

Edit Database Schema

} @else {
- - @if (showClose || isRoutedPage()) { -

Edit database structure with AI

-

Describe the changes you want to make and AI will generate the SQL for you.

- } @else { -

No tables found — let AI create them

-

Describe the database you need and AI will generate the schema for you.

- } +
+ schema +
+

Generate your first table to start managing data

-
- @if (showClose || isRoutedPage()) { - - - - } @else { - - - - } +
+

or start from templates

+
+ + + +
+
} @@ -84,6 +96,29 @@

Edit Database Schema

@if (chatMessages().length === 0 && !submitting()) {

Describe what you need and AI will generate the SQL.

+ + @if (!currentDiagram()) { +
+

Try asking

+
+ + + +
+
+ } } @for (message of chatMessages(); track $index) { @@ -173,7 +208,7 @@

Edit Database Schema

} @else {
- {{showClose || isRoutedPage() ? 'Describe the changes you need...' : 'Describe the database you need...'}} + {{ currentDiagram() ? 'Describe the changes you need...' : 'Describe the database you need...' }}