diff --git a/frontend/package.json b/frontend/package.json index 4e471624b..76776c6ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,6 +61,7 @@ "ngx-cookie-service": "^19.0.0", "ngx-markdown": "^19.1.1", "ngx-stripe": "^19.0.0", + "node-sql-parser": "^5.4.0", "pluralize": "^8.0.0", "postgres-interval": "^4.0.2", "posthog-js": "^1.341.0", 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..400f91490 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,42 +40,81 @@ /* ── 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 { - 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-chat__welcome-title { +.schema-editor__welcome-title { font-size: 16px; font-weight: 500; color: rgba(0, 0, 0, 0.7); @@ -86,7 +122,14 @@ text-align: center; } -.schema-chat__welcome-subtitle { +.schema-editor__welcome-heading { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.schema-editor__welcome-subtitle { font-size: 14px; color: rgba(0, 0, 0, 0.5); margin: 0; @@ -94,34 +137,154 @@ } @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-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); } @@ -136,55 +299,182 @@ } } +/* ── 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: 10px; + min-height: 0; +} + +.schema-editor__messages-empty { + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + margin: 40px 0 0; + line-height: 1.45; +} + +.schema-editor__try-asking-block { + margin-top: auto; display: flex; flex-direction: column; - gap: 12px; + gap: 8px; +} + +@media (prefers-color-scheme: dark) { + .schema-editor__messages-empty { + color: rgba(255, 255, 255, 0.5); + } +} + +.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-chat__message { +.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 { - align-self: flex-start; - background-color: rgba(99, 132, 255, 0.06); +.schema-editor__message--ai { + align-self: stretch; + background-color: transparent; + padding-left: 0; + padding-right: 0; max-width: 100%; } -@media (prefers-color-scheme: dark) { - .schema-chat__message--ai { - background-color: rgba(99, 132, 255, 0.1); - } -} - -.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,98 +483,303 @@ } @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); - border-radius: 6px; +/* ── Structured change card ── */ + +.change-card { + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; padding: 10px 12px; - background: rgba(0, 0, 0, 0.02); + background: #fff; + display: flex; + flex-direction: column; + gap: 8px; } @media (prefers-color-scheme: dark) { - .schema-chat__change-card { - border-color: rgba(255, 255, 255, 0.12); + .change-card { + border-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); } } -.schema-chat__change-header { +.change-card__head { display: flex; align-items: center; - gap: 8px; - margin-bottom: 8px; + gap: 10px; +} + +.change-card__action-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; +} + +.change-card__action-icon mat-icon { + font-size: 16px !important; + width: 16px !important; + height: 16px !important; +} + +.change-card[data-tone="add"] .change-card__action-icon { + background: color-mix(in srgb, #16a34a 14%, transparent); + color: #15803d; +} + +.change-card[data-tone="edit"] .change-card__action-icon { + background: color-mix(in srgb, var(--color-accentedPalette-500) 14%, transparent); + color: var(--color-accentedPalette-500); +} + +.change-card[data-tone="remove"] .change-card__action-icon { + background: color-mix(in srgb, #dc2626 14%, transparent); + color: #dc2626; +} + +@media (prefers-color-scheme: dark) { + .change-card[data-tone="add"] .change-card__action-icon { color: #4ade80; } + .change-card[data-tone="remove"] .change-card__action-icon { color: #f87171; } } -.schema-chat__change-type { - font-size: 10px; +.change-card__head-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.change-card__action { + font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.3px; + letter-spacing: 0.4px; + color: rgba(0, 0, 0, 0.45); +} + +@media (prefers-color-scheme: dark) { + .change-card__action { + color: rgba(255, 255, 255, 0.5); + } +} + +.change-card__target { + font-size: 14px; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: rgba(0, 0, 0, 0.88); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (prefers-color-scheme: dark) { + .change-card__target { + color: rgba(255, 255, 255, 0.92); + } +} + +.change-card__summary { + margin: 0; + font-size: 12.5px; + color: rgba(0, 0, 0, 0.62); + line-height: 1.45; +} + +@media (prefers-color-scheme: dark) { + .change-card__summary { + color: rgba(255, 255, 255, 0.62); + } +} + +.change-card__columns { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: max-content minmax(0, 1fr) auto; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 6px; + overflow: hidden; + font-size: 12px; +} + +@media (prefers-color-scheme: dark) { + .change-card__columns { + border-color: rgba(255, 255, 255, 0.08); + } +} + +.change-card__column { + display: contents; +} + +.change-card__column > * { + display: flex; + align-items: center; + padding: 6px 10px; + border-top: 1px solid rgba(0, 0, 0, 0.04); +} + +.change-card__column:first-child > * { + border-top: none; +} + +@media (prefers-color-scheme: dark) { + .change-card__column > * { + border-top-color: rgba(255, 255, 255, 0.06); + } +} + +.change-card__column-name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 500; + color: rgba(0, 0, 0, 0.85); +} + +@media (prefers-color-scheme: dark) { + .change-card__column-name { + color: rgba(255, 255, 255, 0.88); + } +} + +.change-card__column-type { + color: rgba(0, 0, 0, 0.5); + font-size: 11.5px; + padding-left: 0 !important; +} + +@media (prefers-color-scheme: dark) { + .change-card__column-type { + color: rgba(255, 255, 255, 0.55); + } +} + +.change-card__column-badges { + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; +} + +.change-card__badge { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; padding: 2px 6px; border-radius: 3px; - background: var(--color-primaryPalette-500); - color: white; + line-height: 1.2; + text-transform: uppercase; +} + +.change-card__badge--pk { + background: color-mix(in srgb, #f59e0b 18%, transparent); + color: #b45309; +} + +.change-card__badge--fk { + background: color-mix(in srgb, var(--color-accentedPalette-500) 18%, transparent); + color: var(--color-accentedPalette-500); +} + +.change-card__badge--nn, +.change-card__badge--uq, +.change-card__badge--def { + background: rgba(0, 0, 0, 0.06); + color: rgba(0, 0, 0, 0.6); +} + +@media (prefers-color-scheme: dark) { + .change-card__badge--pk { color: #fbbf24; } + .change-card__badge--nn, + .change-card__badge--uq, + .change-card__badge--def { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); + } +} + +.change-card__sql { + margin: 0; + font-size: 11px; } -.schema-chat__change-table { +.change-card__sql summary { + cursor: pointer; + user-select: none; + font-size: 11px; font-weight: 500; - font-size: 13px; + color: rgba(0, 0, 0, 0.5); + padding: 2px 0; +} + +.change-card__sql summary:hover { + color: var(--color-accentedPalette-500); +} + +@media (prefers-color-scheme: dark) { + .change-card__sql summary { + color: rgba(255, 255, 255, 0.5); + } } -.schema-chat__change-sql { +.change-card__sql pre { background: rgba(0, 0, 0, 0.04); border-radius: 4px; - padding: 10px 12px; - font-size: 12px; - font-family: monospace; + padding: 8px 10px; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-x: auto; white-space: pre-wrap; - margin: 0; + margin: 6px 0 0; } @media (prefers-color-scheme: dark) { - .schema-chat__change-sql { + .change-card__sql pre { 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 +789,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 +815,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 +850,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..237a29114 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,279 @@ -
+
@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.

- } +
+
+ schema +
+
+

Create your first table

+

Describe what you need and AI will generate the SQL

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

or use a template

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

Describe what you need and AI will generate the SQL.

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

{{ message.text }}

-
- - {{ (diagramZoom() * 100).toFixed(0) }}% - -
-
-
- -
+ } + } + + @for (message of chatMessages(); track $index) { + @if (message.role === 'user') { +
+ {{ message.text }} +
+ } + @if (message.role === 'ai') { +
+ {{ message.text }} + + @if (message.parsedChanges?.length) { +
+ @for (change of message.parsedChanges; track change.id) { +
+
+
+ {{ change.icon }} +
+
+ {{ change.action }} + {{ change.targetTableName }} +
+
+ @if (change.aiSummary) { +

{{ change.aiSummary }}

+ } + @if (change.columns.length) { +
    + @for (col of change.columns; track col.name) { +
  • + {{ col.name }} + {{ col.type || '—' }} + + @if (col.pk) { + PK + } + @if (col.fk) { + FK + } + @if (col.notNull && !col.pk) { + NOT NULL + } + @if (col.unique && !col.pk) { + UNIQUE + } + @if (col.defaultValue) { + DEFAULT + } + +
  • + } +
+ } +
+ View SQL +
{{ change.forwardSql }}
+
+
+ } +
+ } +
+ } + @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 { +
+ + {{ currentDiagram() ? '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..98717f98a 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,15 +5,41 @@ 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 { MatTooltipModule } from '@angular/material/tooltip'; +import { TextFieldModule } from '@angular/cdk/text-field'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { MarkdownModule } from 'ngx-markdown'; +import { Parser } from 'node-sql-parser'; import { TableSchemaService, SchemaChangeResponse } from 'src/app/services/table-schema.service'; +import { SchemaDiagramViewerComponent } from './schema-diagram-viewer/schema-diagram-viewer.component'; + +interface ParsedColumn { + name: string; + type: string; + pk: boolean; + fk: boolean; + referenceTable?: string; + notNull: boolean; + unique: boolean; + defaultValue?: string; +} + +interface ParsedChange { + id: string; + action: string; + icon: string; + tone: 'add' | 'edit' | 'remove'; + targetTableName: string; + aiSummary?: string; + columns: ParsedColumn[]; + forwardSql: string; +} interface ChatMessage { role: 'user' | 'ai' | 'error' | 'diagram'; text: string; diagramSource?: string; changes?: SchemaChangeResponse[]; + parsedChanges?: ParsedChange[]; batchId?: string; } @@ -24,12 +50,14 @@ interface ChatMessage { imports: [ CommonModule, FormsModule, - MarkdownModule, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, + MatTooltipModule, + TextFieldModule, RouterModule, + SchemaDiagramViewerComponent, ], }) export class EditDatabaseSchemaComponent implements OnInit { @@ -48,7 +76,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 +87,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'); @@ -92,14 +131,26 @@ export class EditDatabaseSchemaComponent implements OnInit { this._threadId = result.threadId; } 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); + const parsedChanges = result.changes.map(c => this._parseChange(c)); + this.messages.update(msgs => { + const next: ChatMessage[] = [...msgs, { + role: 'ai', + text: `I've generated ${result.changes.length} change(s) for your database. Review them below and approve or reject.`, + changes: result.changes, + parsedChanges, + 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', @@ -159,30 +210,19 @@ export class EditDatabaseSchemaComponent implements OnInit { if (!batch?.batchId) return; await this._tableSchema.rejectBatch(batch.batchId); - this.messages.update(msgs => msgs.map(m => - m === batch ? { ...m, batchId: undefined } : m - ).concat({ - role: 'ai', - text: 'Changes rejected. Feel free to describe what you need differently.', - })); + this.messages.update(msgs => msgs + .filter(m => !(m.role === 'diagram' && m.text === 'Schema Preview')) + .map(m => m === batch ? { ...m, batchId: undefined } : m) + .concat({ + role: 'ai', + text: 'Changes rejected. Feel free to describe what you need differently.', + })); } onOpenTables() { 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(); @@ -194,11 +234,12 @@ export class EditDatabaseSchemaComponent implements OnInit { this.initialDiagramLoading.set(true); try { const diagram = await this._tableSchema.fetchDiagram(this.connectionID); - if (diagram?.diagram) { + const source = diagram?.diagram ?? ''; + if (this._mermaidHasEntities(source)) { this.messages.update(msgs => [...msgs, { role: 'diagram' as const, text: label, - diagramSource: '```mermaid\n' + diagram.diagram + '\n```', + diagramSource: '```mermaid\n' + source + '\n```', }]); } } catch { @@ -207,4 +248,276 @@ export class EditDatabaseSchemaComponent implements OnInit { this.initialDiagramLoading.set(false); } } + + private _mermaidHasEntities(source: string): boolean { + if (!source) return false; + for (const line of source.split('\n')) { + if (/^\s*[A-Za-z_][\w-]*\s*\{/.test(line)) return true; + } + return false; + } + + private _buildMermaidFromChanges(changes: SchemaChangeResponse[]): string { + const parser = new Parser(); + const tables: { name: string; columns: { type: string; name: string; pk: boolean; fk: boolean }[] }[] = []; + const relationKeys = new Set(); + const relations: { from: string; to: string }[] = []; + + for (const change of changes) { + const sql = change.forwardSql?.trim(); + if (!sql) continue; + + let parsed: unknown; + try { + parsed = parser.astify(sql, { database: 'PostgresQL' }); + } catch { + try { + parsed = parser.astify(sql, { database: 'MySQL' }); + } catch { + continue; + } + } + + const nodes = Array.isArray(parsed) ? parsed : [parsed]; + for (const node of nodes) { + const ast = node as Record | null; + if (!ast || ast['type'] !== 'create' || ast['keyword'] !== 'table') continue; + + const tableName = this._extractTableName(ast['table']); + if (!tableName) continue; + + const columns: { type: string; name: string; pk: boolean; fk: boolean }[] = []; + const colIndex = new Map(); + const defs = (ast['create_definitions'] as unknown[]) ?? []; + + for (const rawDef of defs) { + const def = rawDef as Record; + if (def['resource'] === 'column') { + const colName = this._extractColumnName(def['column']); + if (!colName) continue; + const dataType = (def['definition'] as { dataType?: string } | undefined)?.dataType ?? ''; + const primary = def['primary'] as string | undefined; + const pk = primary === 'primary key' || primary === 'key'; + const ref = this._extractReferenceTable(def['reference_definition']); + const fk = !!ref; + if (ref) this._pushRelation(relations, relationKeys, tableName, ref); + colIndex.set(colName, columns.length); + columns.push({ name: colName, type: dataType.toLowerCase(), pk, fk }); + } else if (def['resource'] === 'constraint') { + const ctype = (def['constraint_type'] as string | undefined)?.toLowerCase(); + const colRefs = (def['definition'] as unknown[]) ?? []; + if (ctype === 'primary key') { + for (const c of colRefs) { + const name = this._extractColumnName(c); + if (!name) continue; + const i = colIndex.get(name); + if (i !== undefined) columns[i].pk = true; + } + } else if (ctype === 'foreign key') { + const ref = this._extractReferenceTable(def['reference_definition']); + if (!ref) continue; + for (const c of colRefs) { + const name = this._extractColumnName(c); + if (name) { + const i = colIndex.get(name); + if (i !== undefined) columns[i].fk = true; + } + } + this._pushRelation(relations, relationKeys, tableName, ref); + } + } + } + + 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; + } + + private _extractTableName(value: unknown): string | null { + if (!value) return null; + const first = Array.isArray(value) ? value[0] : value; + const name = (first as { table?: unknown })?.table; + return typeof name === 'string' ? name : null; + } + + private _extractColumnName(value: unknown): string | null { + if (!value) return null; + const inner = (value as { column?: unknown }).column ?? value; + if (typeof inner === 'string') return inner; + const expr = (inner as { expr?: { value?: unknown } })?.expr; + if (expr && typeof expr.value === 'string') return expr.value; + return null; + } + + private _extractReferenceTable(value: unknown): string | null { + if (!value) return null; + const inner = (value as { reference_definition?: unknown }).reference_definition ?? value; + const tables = (inner as { table?: unknown }).table; + const first = Array.isArray(tables) ? tables[0] : tables; + const name = (first as { table?: unknown })?.table; + return typeof name === 'string' ? name : null; + } + + private _pushRelation( + relations: { from: string; to: string }[], + seen: Set, + from: string, + to: string, + ): void { + const key = `${from}|${to}`; + if (seen.has(key)) return; + seen.add(key); + relations.push({ from, to }); + } + + private _changeMeta(changeType: string): { action: string; icon: string; tone: 'add' | 'edit' | 'remove' } { + const map: Record = { + CREATE_TABLE: { action: 'Create table', icon: 'add_box', tone: 'add' }, + DROP_TABLE: { action: 'Drop table', icon: 'delete_outline', tone: 'remove' }, + ADD_COLUMN: { action: 'Add column', icon: 'add', tone: 'add' }, + DROP_COLUMN: { action: 'Drop column', icon: 'remove', tone: 'remove' }, + ALTER_COLUMN: { action: 'Modify column', icon: 'edit', tone: 'edit' }, + ADD_INDEX: { action: 'Add index', icon: 'bookmark_add', tone: 'add' }, + DROP_INDEX: { action: 'Drop index', icon: 'bookmark_remove', tone: 'remove' }, + ADD_FOREIGN_KEY: { action: 'Add foreign key', icon: 'link', tone: 'add' }, + DROP_FOREIGN_KEY: { action: 'Drop foreign key', icon: 'link_off', tone: 'remove' }, + ADD_PRIMARY_KEY: { action: 'Add primary key', icon: 'vpn_key', tone: 'add' }, + DROP_PRIMARY_KEY: { action: 'Drop primary key', icon: 'key_off', tone: 'remove' }, + MONGO_CREATE_COLLECTION: { action: 'Create collection', icon: 'add_box', tone: 'add' }, + MONGO_DROP_COLLECTION: { action: 'Drop collection', icon: 'delete_outline', tone: 'remove' }, + MONGO_SET_VALIDATOR: { action: 'Set validator', icon: 'rule', tone: 'edit' }, + MONGO_CREATE_INDEX: { action: 'Create index', icon: 'bookmark_add', tone: 'add' }, + MONGO_DROP_INDEX: { action: 'Drop index', icon: 'bookmark_remove', tone: 'remove' }, + DYNAMODB_CREATE_TABLE: { action: 'Create table', icon: 'add_box', tone: 'add' }, + DYNAMODB_DROP_TABLE: { action: 'Drop table', icon: 'delete_outline', tone: 'remove' }, + DYNAMODB_UPDATE_TABLE: { action: 'Update table', icon: 'edit', tone: 'edit' }, + ELASTICSEARCH_CREATE_INDEX: { action: 'Create index', icon: 'add_box', tone: 'add' }, + ELASTICSEARCH_DELETE_INDEX: { action: 'Delete index', icon: 'delete_outline', tone: 'remove' }, + ELASTICSEARCH_UPDATE_MAPPING: { action: 'Update mapping', icon: 'edit', tone: 'edit' }, + ROLLBACK: { action: 'Rollback', icon: 'undo', tone: 'edit' }, + }; + const upper = (changeType ?? '').toUpperCase(); + return map[upper] ?? { + action: upper.replace(/_/g, ' ').toLowerCase().replace(/\b\w/, c => c.toUpperCase()), + icon: 'code', + tone: 'edit', + }; + } + + private _parseChange(change: SchemaChangeResponse): ParsedChange { + const meta = this._changeMeta(change.changeType); + const parsed: ParsedChange = { + id: change.id, + action: meta.action, + icon: meta.icon, + tone: meta.tone, + targetTableName: change.targetTableName, + aiSummary: change.aiSummary, + columns: [], + forwardSql: change.forwardSql, + }; + if ((change.changeType ?? '').toUpperCase() === 'CREATE_TABLE' && change.forwardSql) { + parsed.columns = this._parseCreateTableColumns(change.forwardSql); + } + return parsed; + } + + private _parseCreateTableColumns(sql: string): ParsedColumn[] { + const parser = new Parser(); + let ast: unknown; + try { + ast = parser.astify(sql, { database: 'PostgresQL' }); + } catch { + try { + ast = parser.astify(sql, { database: 'MySQL' }); + } catch { + return []; + } + } + const nodes = Array.isArray(ast) ? ast : [ast]; + const columns: ParsedColumn[] = []; + const colIndex = new Map(); + for (const node of nodes) { + const root = node as Record | null; + if (!root || root['type'] !== 'create' || root['keyword'] !== 'table') continue; + const defs = (root['create_definitions'] as unknown[]) ?? []; + for (const rawDef of defs) { + const def = rawDef as Record; + if (def['resource'] === 'column') { + const name = this._extractColumnName(def['column']); + if (!name) continue; + const dataType = (def['definition'] as { dataType?: string } | undefined)?.dataType ?? ''; + const primary = def['primary'] as string | undefined; + const nullable = (def['nullable'] as { value?: string } | undefined)?.value; + const ref = this._extractReferenceTable(def['reference_definition']); + colIndex.set(name, columns.length); + columns.push({ + name, + type: dataType.toLowerCase(), + pk: primary === 'primary key' || primary === 'key', + fk: !!ref, + referenceTable: ref ?? undefined, + notNull: nullable === 'not null', + unique: !!def['unique'], + defaultValue: this._extractDefault(def['default_val']), + }); + } else if (def['resource'] === 'constraint') { + const ctype = (def['constraint_type'] as string | undefined)?.toLowerCase(); + const colRefs = (def['definition'] as unknown[]) ?? []; + if (ctype === 'primary key') { + for (const c of colRefs) { + const colName = this._extractColumnName(c); + if (!colName) continue; + const i = colIndex.get(colName); + if (i !== undefined) columns[i].pk = true; + } + } else if (ctype === 'foreign key') { + const refTable = this._extractReferenceTable(def['reference_definition']); + for (const c of colRefs) { + const colName = this._extractColumnName(c); + if (!colName) continue; + const i = colIndex.get(colName); + if (i !== undefined) { + columns[i].fk = true; + if (refTable) columns[i].referenceTable = refTable; + } + } + } + } + } + } + return columns; + } + + private _extractDefault(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const inner = (value as { value?: { type?: string; value?: unknown } }).value; + if (!inner) return undefined; + if (typeof inner === 'string' || typeof inner === 'number' || typeof inner === 'boolean') return String(inner); + const v = (inner as { value?: unknown }).value; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v); + if ((inner as { type?: string }).type === 'function') { + const name = ((inner as { name?: { name?: { value?: string }[] } }).name?.name?.[0]?.value) ?? ''; + return name ? `${name.toUpperCase()}()` : undefined; + } + return undefined; + } } 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..122e41070 --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css @@ -0,0 +1,312 @@ +: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; +} + +/* ── Dark-theme purple safety net for ER diagram ── */ + +@media (prefers-color-scheme: dark) { + .schema-diagram__canvas ::ng-deep svg rect, + .schema-diagram__canvas ::ng-deep svg .er.entityBox, + .schema-diagram__canvas ::ng-deep svg .er.attributeBoxOdd, + .schema-diagram__canvas ::ng-deep svg .er.attributeBoxEven { + fill: #1d143a !important; + stroke: #3d2b6e !important; + } + .schema-diagram__canvas ::ng-deep svg text, + .schema-diagram__canvas ::ng-deep svg tspan { + fill: #ede9fe !important; + stroke: none !important; + } + .schema-diagram__canvas ::ng-deep svg .er.relationshipLine, + .schema-diagram__canvas ::ng-deep svg path.relationshipLine { + stroke: #a78bfa !important; + } + .schema-diagram__canvas ::ng-deep svg marker path, + .schema-diagram__canvas ::ng-deep svg defs marker path { + fill: #a78bfa !important; + stroke: #a78bfa !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)); } +} + 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..5ab3edaf4 --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html @@ -0,0 +1,62 @@ +
+
+

{{ title }}

+
+ + + + {{ scaleLabel() }} + + + +
+
+ +
+
+
+ +
+ + @if (sidebarOpen()) { + + } + +
+
+
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..095f9386f --- /dev/null +++ b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts @@ -0,0 +1,334 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, + computed, + 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_RELATIVE = 0.2; +const MAX_RELATIVE = 8; +const ABSOLUTE_MIN = 0.05; +const ABSOLUTE_MAX = 40; +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); + this.svgSize.set({ w: 0, h: 0 }); + this.scale.set(1); + this.baseScale.set(1); + this.translateX.set(0); + this.translateY.set(0); + } + get source(): string { + return this._source(); + } + + @ViewChild('viewport') viewportRef!: ElementRef; + @ViewChild('canvas') canvasRef!: 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 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 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(() => { + const base = this.baseScale() || 1; + return `${Math.round((this.scale() / base) * 100)}%`; + }); + + 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(); + + ngAfterViewInit(): void { + this._observeRender(); + this._observeResize(); + } + + ngOnDestroy(): void { + this._renderObserver?.disconnect(); + this._resizeObserver?.disconnect(); + document.removeEventListener('mousemove', this._onPanMove); + document.removeEventListener('mouseup', this._onPanEnd); + } + + onZoomIn() { + this._zoomAt(this.scale() + this.baseScale() * ZOOM_STEP); + } + + onZoomOut() { + this._zoomAt(this.scale() - this.baseScale() * ZOOM_STEP); + } + + onZoomReset() { + this.onFitToScreen(); + } + + onFitToScreen() { + 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; + const fit = Math.min( + (vp.w - padding * 2) / svg.w, + (vp.h - padding * 2) / svg.h, + ); + 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); + } + + onPanStart(event: MouseEvent) { + const target = event.target as HTMLElement; + if (target.closest('button, a, input, .schema-diagram__sidebar, .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); + } + + onToggleSidebar() { + this.sidebarOpen.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 _zoomAt(targetScale: number, originX?: number, originY?: number) { + 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(); + 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 _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.svgReady.set(true); + requestAnimationFrame(() => this.onFitToScreen()); + setTimeout(() => this.onFitToScreen(), 80); + }; + tryAttach(); + this._renderObserver?.disconnect(); + this._renderObserver = new MutationObserver(() => tryAttach()); + this._renderObserver.observe(canvas, { childList: true, subtree: true }); + } + + private _observeResize() { + const viewport = this.viewportRef?.nativeElement; + if (!viewport) return; + this._resizeObserver = new ResizeObserver(() => { + const vpRect = viewport.getBoundingClientRect(); + this.viewportSize.set({ w: vpRect.width, h: vpRect.height }); + }); + this._resizeObserver.observe(viewport); + } + + 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 }); + }; + measure(); + requestAnimationFrame(measure); + } + + 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); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e94129cf4..50a9dc9a0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -128,9 +128,34 @@ bootstrapApplication(AppComponent, { provideMarkdown({ mermaidOptions: { provide: MERMAID_OPTIONS, - useValue: { + useValue: window.matchMedia('(prefers-color-scheme: dark)').matches ? { startOnLoad: false, - theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default', + theme: 'base', + themeVariables: { + darkMode: true, + background: '#15102b', + primaryColor: '#1d143a', + primaryBorderColor: '#3d2b6e', + primaryTextColor: '#ede9fe', + secondaryColor: '#251a47', + tertiaryColor: '#1d143a', + lineColor: '#a78bfa', + mainBkg: '#1d143a', + nodeBkg: '#1d143a', + nodeBorder: '#3d2b6e', + clusterBkg: '#1d143a', + clusterBorder: '#3d2b6e', + attributeBackgroundColorOdd: '#1d143a', + attributeBackgroundColorEven: '#1d143a', + titleColor: '#ede9fe', + edgeLabelBackground: '#1d143a', + textColor: '#ede9fe', + labelTextColor: '#ede9fe', + nodeTextColor: '#ede9fe', + }, + } : { + startOnLoad: false, + theme: 'default', }, }, }), diff --git a/frontend/yarn.lock b/frontend/yarn.lock index dde84d26b..4deec118b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5702,6 +5702,13 @@ __metadata: languageName: node linkType: hard +"@types/pegjs@npm:^0.10.0": + version: 0.10.6 + resolution: "@types/pegjs@npm:0.10.6" + checksum: be219504714e219b37daee7ef3214b6876d98405cc56b2d084763134032fd46394c5d0e387216ee3e52bd519fe7341e25bdec855f2a911c49a593b21fd8ea4a6 + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.14.0 resolution: "@types/qs@npm:6.14.0" @@ -6699,6 +6706,13 @@ __metadata: languageName: node linkType: hard +"big-integer@npm:^1.6.48": + version: 1.6.52 + resolution: "big-integer@npm:1.6.52" + checksum: 6e86885787a20fed96521958ae9086960e4e4b5e74d04f3ef7513d4d0ad631a9f3bde2730fc8aaa4b00419fc865f6ec573e5320234531ef37505da7da192c40b + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -11446,6 +11460,16 @@ __metadata: languageName: node linkType: hard +"node-sql-parser@npm:^5.4.0": + version: 5.4.0 + resolution: "node-sql-parser@npm:5.4.0" + dependencies: + "@types/pegjs": ^0.10.0 + big-integer: ^1.6.48 + checksum: 1cb296d3551912b523da1cf7c7b8f4c1c8fbba560f04ca90a7aa287e6ccde4cd8945d3056dadb128f0db29f5a710f521a3c163c1594131802f31b23f51963220 + languageName: node + linkType: hard + "nopt@npm:^9.0.0": version: 9.0.0 resolution: "nopt@npm:9.0.0" @@ -12804,6 +12828,7 @@ __metadata: ngx-cookie-service: ^19.0.0 ngx-markdown: ^19.1.1 ngx-stripe: ^19.0.0 + node-sql-parser: ^5.4.0 playwright: ^1.57.0 pluralize: ^8.0.0 postgres-interval: ^4.0.2