pglens 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -7
- package/client/app.js +540 -4
- package/client/styles.css +300 -3
- package/package.json +1 -1
- package/pglens-1.1.0.tgz +0 -0
- package/src/routes/api.js +150 -1
- package/pglens-1.0.0.tgz +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,20 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [1.1.0] - 2025-11-21
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
11
|
+
|
|
12
|
+
- Cell content popup dialog for viewing full cell values (double-click any table cell)
|
|
13
|
+
- JSON formatting support for JSONB and JSON values in cell content popup with proper indentation
|
|
14
|
+
- Timezone selector for date/time values with multi-timezone display
|
|
15
|
+
- Select All button in column selector for quick column visibility management
|
|
16
|
+
- Copy-to-clipboard functionality in cell content popup
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- JSONB columns now display as readable JSON instead of showing as object
|
|
14
21
|
|
|
15
22
|
### Changed
|
|
16
|
-
|
|
17
|
-
-
|
|
23
|
+
|
|
24
|
+
- Table cells now truncate with ellipsis for better table readability
|
|
25
|
+
- Improved cell content viewing experience with dedicated popup dialog
|
|
18
26
|
|
|
19
27
|
## [1.0.0] - Initial Release
|
|
20
28
|
|
|
21
29
|
### Added
|
|
30
|
+
|
|
22
31
|
- PostgreSQL database viewer with web interface
|
|
23
32
|
- Table browser with searchable sidebar
|
|
24
33
|
- Multi-tab support for viewing multiple tables simultaneously
|
|
@@ -31,11 +40,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
31
40
|
- Automatic primary key detection for optimized pagination
|
|
32
41
|
- Refresh data functionality
|
|
33
42
|
- PM2 deployment support documentation
|
|
43
|
+
- SSL mode configuration via `--sslmode` flag with support for: `disable`, `require`, `prefer`, `verify-ca`, `verify-full`
|
|
44
|
+
- Automatic SSL mode recommendations when connection fails
|
|
45
|
+
- Enhanced error messages with context-aware suggestions
|
|
34
46
|
|
|
35
47
|
### Security
|
|
48
|
+
|
|
36
49
|
- SQL injection prevention via table name sanitization
|
|
37
50
|
- Input validation for pagination parameters
|
|
38
51
|
|
|
39
|
-
[
|
|
52
|
+
[1.1.0]: https://github.com/tsvillain/pglens/compare/v1.0.0...v1.1.0
|
|
40
53
|
[1.0.0]: https://github.com/tsvillain/pglens/releases/tag/v1.0.0
|
|
41
54
|
|
package/client/app.js
CHANGED
|
@@ -424,6 +424,27 @@ function handleRefresh() {
|
|
|
424
424
|
loadTableData();
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Check if a value is a JSON object or array (JSONB/JSON type).
|
|
429
|
+
* Excludes null, Date objects, and other non-JSON types.
|
|
430
|
+
* @param {*} value - Value to check
|
|
431
|
+
* @returns {boolean} True if value is a JSON object or array
|
|
432
|
+
*/
|
|
433
|
+
function isJsonValue(value) {
|
|
434
|
+
if (value === null || value === undefined) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (typeof value === 'object') {
|
|
439
|
+
if (value instanceof Date) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
return Array.isArray(value) || Object.prototype.toString.call(value) === '[object Object]';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
427
448
|
/**
|
|
428
449
|
* Load table data for the active tab.
|
|
429
450
|
* Uses cursor-based pagination for tables with primary keys (more efficient for large datasets).
|
|
@@ -538,7 +559,6 @@ function renderTable(data) {
|
|
|
538
559
|
|
|
539
560
|
visibleColumns.forEach((column, index) => {
|
|
540
561
|
const th = document.createElement('th');
|
|
541
|
-
th.textContent = column;
|
|
542
562
|
th.className = 'sortable resizable';
|
|
543
563
|
th.dataset.column = column;
|
|
544
564
|
|
|
@@ -549,6 +569,78 @@ function renderTable(data) {
|
|
|
549
569
|
th.style.minWidth = '120px';
|
|
550
570
|
}
|
|
551
571
|
|
|
572
|
+
const columnHeader = document.createElement('div');
|
|
573
|
+
columnHeader.className = 'column-header';
|
|
574
|
+
|
|
575
|
+
// Column name with key badges
|
|
576
|
+
const columnNameRow = document.createElement('div');
|
|
577
|
+
columnNameRow.className = 'column-name-row';
|
|
578
|
+
|
|
579
|
+
const columnName = document.createElement('div');
|
|
580
|
+
columnName.className = 'column-name';
|
|
581
|
+
columnName.textContent = column;
|
|
582
|
+
columnNameRow.appendChild(columnName);
|
|
583
|
+
|
|
584
|
+
const columnMeta = data.columns && data.columns[column] ? data.columns[column] : null;
|
|
585
|
+
|
|
586
|
+
let dataType = '';
|
|
587
|
+
let isPrimaryKey = false;
|
|
588
|
+
let isForeignKey = false;
|
|
589
|
+
let foreignKeyRef = null;
|
|
590
|
+
let isUnique = false;
|
|
591
|
+
|
|
592
|
+
if (columnMeta) {
|
|
593
|
+
if (typeof columnMeta === 'string') {
|
|
594
|
+
dataType = columnMeta;
|
|
595
|
+
} else {
|
|
596
|
+
dataType = columnMeta.dataType || '';
|
|
597
|
+
isPrimaryKey = columnMeta.isPrimaryKey || false;
|
|
598
|
+
isForeignKey = columnMeta.isForeignKey || false;
|
|
599
|
+
foreignKeyRef = columnMeta.foreignKeyRef || null;
|
|
600
|
+
isUnique = columnMeta.isUnique || false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const keyBadges = document.createElement('div');
|
|
605
|
+
keyBadges.className = 'key-badges';
|
|
606
|
+
|
|
607
|
+
if (isPrimaryKey) {
|
|
608
|
+
const pkBadge = document.createElement('span');
|
|
609
|
+
pkBadge.className = 'key-badge key-badge-pk';
|
|
610
|
+
pkBadge.textContent = 'PK';
|
|
611
|
+
pkBadge.title = 'Primary Key';
|
|
612
|
+
keyBadges.appendChild(pkBadge);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (isForeignKey && foreignKeyRef) {
|
|
616
|
+
const fkBadge = document.createElement('span');
|
|
617
|
+
fkBadge.className = 'key-badge key-badge-fk';
|
|
618
|
+
fkBadge.textContent = 'FK';
|
|
619
|
+
fkBadge.title = `Foreign Key → ${foreignKeyRef.table}.${foreignKeyRef.column}`;
|
|
620
|
+
keyBadges.appendChild(fkBadge);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (isUnique && !isPrimaryKey) {
|
|
624
|
+
const uqBadge = document.createElement('span');
|
|
625
|
+
uqBadge.className = 'key-badge key-badge-uq';
|
|
626
|
+
uqBadge.textContent = 'UQ';
|
|
627
|
+
uqBadge.title = 'Unique Constraint';
|
|
628
|
+
keyBadges.appendChild(uqBadge);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (keyBadges.children.length > 0) {
|
|
632
|
+
columnNameRow.appendChild(keyBadges);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Column datatype
|
|
636
|
+
const columnDatatype = document.createElement('div');
|
|
637
|
+
columnDatatype.className = 'column-datatype';
|
|
638
|
+
columnDatatype.textContent = dataType;
|
|
639
|
+
|
|
640
|
+
columnHeader.appendChild(columnNameRow);
|
|
641
|
+
columnHeader.appendChild(columnDatatype);
|
|
642
|
+
th.appendChild(columnHeader);
|
|
643
|
+
|
|
552
644
|
if (tab.sortColumn === column) {
|
|
553
645
|
th.classList.add(`sorted-${tab.sortDirection}`);
|
|
554
646
|
}
|
|
@@ -597,11 +689,34 @@ function renderTable(data) {
|
|
|
597
689
|
}
|
|
598
690
|
|
|
599
691
|
const value = row[column];
|
|
692
|
+
|
|
693
|
+
// Store original value for popup
|
|
694
|
+
td.dataset.originalValue = value !== null && value !== undefined
|
|
695
|
+
? (isJsonValue(value) ? JSON.stringify(value, null, 2) : String(value))
|
|
696
|
+
: 'NULL';
|
|
697
|
+
td.dataset.columnName = column;
|
|
698
|
+
|
|
699
|
+
td.addEventListener('dblclick', (e) => {
|
|
700
|
+
e.stopPropagation();
|
|
701
|
+
showCellContentPopup(column, value);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
td.style.cursor = 'pointer';
|
|
705
|
+
|
|
600
706
|
if (value === null || value === undefined) {
|
|
601
707
|
const nullSpan = document.createElement('span');
|
|
602
708
|
nullSpan.className = 'null-value';
|
|
603
709
|
nullSpan.textContent = 'NULL';
|
|
604
710
|
td.appendChild(nullSpan);
|
|
711
|
+
} else if (isJsonValue(value)) {
|
|
712
|
+
const jsonPre = document.createElement('pre');
|
|
713
|
+
jsonPre.className = 'json-value';
|
|
714
|
+
try {
|
|
715
|
+
jsonPre.textContent = JSON.stringify(value, null, 2);
|
|
716
|
+
} catch (e) {
|
|
717
|
+
jsonPre.textContent = String(value);
|
|
718
|
+
}
|
|
719
|
+
td.appendChild(jsonPre);
|
|
605
720
|
} else {
|
|
606
721
|
td.textContent = String(value);
|
|
607
722
|
}
|
|
@@ -636,6 +751,7 @@ function setupColumnSelector(tab, columns, tableHeader) {
|
|
|
636
751
|
const columnButton = tableHeader.querySelector('#columnButton');
|
|
637
752
|
const columnMenu = tableHeader.querySelector('#columnMenu');
|
|
638
753
|
const columnMenuOptions = tableHeader.querySelector('#columnMenuOptions');
|
|
754
|
+
const columnMenuHeader = tableHeader.querySelector('.column-menu-header');
|
|
639
755
|
|
|
640
756
|
if (!columnButton || !columnMenu || !columnMenuOptions) {
|
|
641
757
|
console.warn('Column selector elements not found');
|
|
@@ -644,6 +760,55 @@ function setupColumnSelector(tab, columns, tableHeader) {
|
|
|
644
760
|
|
|
645
761
|
columnMenuOptions.innerHTML = '';
|
|
646
762
|
|
|
763
|
+
// Check if any columns are hidden
|
|
764
|
+
const hasHiddenColumns = tab.hiddenColumns && tab.hiddenColumns.length > 0;
|
|
765
|
+
|
|
766
|
+
if (columnMenuHeader) {
|
|
767
|
+
let headerTitle = columnMenuHeader.querySelector('.column-menu-header-title');
|
|
768
|
+
if (!headerTitle) {
|
|
769
|
+
const headerText = columnMenuHeader.textContent.trim();
|
|
770
|
+
columnMenuHeader.innerHTML = '';
|
|
771
|
+
headerTitle = document.createElement('span');
|
|
772
|
+
headerTitle.className = 'column-menu-header-title';
|
|
773
|
+
headerTitle.textContent = headerText || 'Columns';
|
|
774
|
+
columnMenuHeader.appendChild(headerTitle);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let selectAllButton = columnMenuHeader.querySelector('.column-select-all-button');
|
|
778
|
+
if (hasHiddenColumns) {
|
|
779
|
+
if (!selectAllButton) {
|
|
780
|
+
selectAllButton = document.createElement('button');
|
|
781
|
+
selectAllButton.className = 'column-select-all-button';
|
|
782
|
+
selectAllButton.textContent = 'Select All';
|
|
783
|
+
selectAllButton.title = 'Show all columns';
|
|
784
|
+
selectAllButton.addEventListener('click', (e) => {
|
|
785
|
+
e.stopPropagation();
|
|
786
|
+
// Show all columns
|
|
787
|
+
tab.hiddenColumns = [];
|
|
788
|
+
if (tab.data) {
|
|
789
|
+
renderTable(tab.data);
|
|
790
|
+
requestAnimationFrame(() => {
|
|
791
|
+
const newTableHeader = document.querySelector('.table-header');
|
|
792
|
+
if (newTableHeader) {
|
|
793
|
+
const newColumnMenu = newTableHeader.querySelector('#columnMenu');
|
|
794
|
+
if (newColumnMenu) {
|
|
795
|
+
newColumnMenu.style.display = 'block';
|
|
796
|
+
const columns = Object.keys(tab.data.rows[0] || {});
|
|
797
|
+
setupColumnSelector(tab, columns, newTableHeader);
|
|
798
|
+
updateColumnButtonLabel(tab, columns, newTableHeader);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
columnMenuHeader.appendChild(selectAllButton);
|
|
805
|
+
}
|
|
806
|
+
selectAllButton.style.display = 'block';
|
|
807
|
+
} else if (selectAllButton) {
|
|
808
|
+
selectAllButton.style.display = 'none';
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
647
812
|
columns.forEach(column => {
|
|
648
813
|
const label = document.createElement('label');
|
|
649
814
|
label.className = 'column-option';
|
|
@@ -836,9 +1001,26 @@ function getSortedRows(rows, tab) {
|
|
|
836
1001
|
return tab.sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
837
1002
|
}
|
|
838
1003
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1004
|
+
let aStr, bStr;
|
|
1005
|
+
if (isJsonValue(aVal)) {
|
|
1006
|
+
try {
|
|
1007
|
+
aStr = JSON.stringify(aVal).toLowerCase();
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
aStr = String(aVal).toLowerCase();
|
|
1010
|
+
}
|
|
1011
|
+
} else {
|
|
1012
|
+
aStr = String(aVal).toLowerCase();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (isJsonValue(bVal)) {
|
|
1016
|
+
try {
|
|
1017
|
+
bStr = JSON.stringify(bVal).toLowerCase();
|
|
1018
|
+
} catch (e) {
|
|
1019
|
+
bStr = String(bVal).toLowerCase();
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
bStr = String(bVal).toLowerCase();
|
|
1023
|
+
}
|
|
842
1024
|
|
|
843
1025
|
if (tab.sortDirection === 'asc') {
|
|
844
1026
|
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
|
|
@@ -926,3 +1108,357 @@ function handlePageChange(newPage) {
|
|
|
926
1108
|
}
|
|
927
1109
|
|
|
928
1110
|
window.handlePageChange = handlePageChange;
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Check if a value is a date/time value and parse it.
|
|
1114
|
+
* Detects Date objects, ISO date strings, and PostgreSQL timestamp strings.
|
|
1115
|
+
* @param {*} value - Value to check
|
|
1116
|
+
* @returns {Date|null} Parsed Date object if valid date/time, null otherwise
|
|
1117
|
+
*/
|
|
1118
|
+
function isDateTimeValue(value) {
|
|
1119
|
+
if (value === null || value === undefined) {
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (value instanceof Date) {
|
|
1124
|
+
return isNaN(value.getTime()) ? null : value;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (typeof value === 'string') {
|
|
1128
|
+
const trimmed = value.trim();
|
|
1129
|
+
if (trimmed === '' || trimmed === 'NULL') {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Try parsing as ISO date string or PostgreSQL timestamp
|
|
1134
|
+
// PostgreSQL timestamps: '2024-01-01 12:00:00' or '2024-01-01 12:00:00.123' or with timezone
|
|
1135
|
+
// ISO strings: '2024-01-01T12:00:00' or '2024-01-01T12:00:00Z' or with timezone offset
|
|
1136
|
+
const date = new Date(trimmed);
|
|
1137
|
+
if (!isNaN(date.getTime())) {
|
|
1138
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}/;
|
|
1139
|
+
if (datePattern.test(trimmed)) {
|
|
1140
|
+
return date;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Get user's current timezone.
|
|
1150
|
+
* @returns {string} IANA timezone identifier (e.g., 'America/New_York')
|
|
1151
|
+
*/
|
|
1152
|
+
function getCurrentTimezone() {
|
|
1153
|
+
try {
|
|
1154
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
return 'UTC';
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Get list of common timezones.
|
|
1162
|
+
* @returns {Array<{value: string, label: string}>} Array of timezone objects
|
|
1163
|
+
*/
|
|
1164
|
+
function getCommonTimezones() {
|
|
1165
|
+
const timezones = [
|
|
1166
|
+
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
|
1167
|
+
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
|
|
1168
|
+
{ value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' },
|
|
1169
|
+
{ value: 'America/Denver', label: 'America/Denver (MST/MDT)' },
|
|
1170
|
+
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
|
|
1171
|
+
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
|
|
1172
|
+
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
|
|
1173
|
+
{ value: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
|
|
1174
|
+
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
|
|
1175
|
+
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
|
|
1176
|
+
{ value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
|
|
1177
|
+
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
|
|
1178
|
+
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' },
|
|
1179
|
+
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZDT/NZST)' },
|
|
1180
|
+
];
|
|
1181
|
+
|
|
1182
|
+
// Add current timezone if not already in list
|
|
1183
|
+
const currentTz = getCurrentTimezone();
|
|
1184
|
+
const hasCurrent = timezones.some(tz => tz.value === currentTz);
|
|
1185
|
+
if (!hasCurrent && currentTz !== 'UTC') {
|
|
1186
|
+
timezones.unshift({ value: currentTz, label: `${currentTz} (Current)` });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return timezones;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Format date/time in specified timezone.
|
|
1194
|
+
* @param {Date} date - Date object to format
|
|
1195
|
+
* @param {string} timezone - IANA timezone identifier
|
|
1196
|
+
* @returns {string} Formatted date/time string with timezone info
|
|
1197
|
+
*/
|
|
1198
|
+
function formatDateTimeInTimezone(date, timezone) {
|
|
1199
|
+
try {
|
|
1200
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
1201
|
+
timeZone: timezone,
|
|
1202
|
+
year: 'numeric',
|
|
1203
|
+
month: '2-digit',
|
|
1204
|
+
day: '2-digit',
|
|
1205
|
+
hour: '2-digit',
|
|
1206
|
+
minute: '2-digit',
|
|
1207
|
+
second: '2-digit',
|
|
1208
|
+
fractionalSecondDigits: 3,
|
|
1209
|
+
hour12: false,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
const parts = formatter.formatToParts(date);
|
|
1213
|
+
const year = parts.find(p => p.type === 'year').value;
|
|
1214
|
+
const month = parts.find(p => p.type === 'month').value;
|
|
1215
|
+
const day = parts.find(p => p.type === 'day').value;
|
|
1216
|
+
const hour = parts.find(p => p.type === 'hour').value;
|
|
1217
|
+
const minute = parts.find(p => p.type === 'minute').value;
|
|
1218
|
+
const second = parts.find(p => p.type === 'second').value;
|
|
1219
|
+
const fractionalSecond = parts.find(p => p.type === 'fractionalSecond')?.value || '';
|
|
1220
|
+
|
|
1221
|
+
const tzFormatter = new Intl.DateTimeFormat('en-US', {
|
|
1222
|
+
timeZone: timezone,
|
|
1223
|
+
timeZoneName: 'short',
|
|
1224
|
+
});
|
|
1225
|
+
const tzParts = tzFormatter.formatToParts(date);
|
|
1226
|
+
const tzName = tzParts.find(p => p.type === 'timeZoneName')?.value || timezone;
|
|
1227
|
+
|
|
1228
|
+
const dateStr = `${year}-${month}-${day}`;
|
|
1229
|
+
const timeStr = `${hour}:${minute}:${second}${fractionalSecond ? '.' + fractionalSecond : ''}`;
|
|
1230
|
+
|
|
1231
|
+
return `${dateStr} ${timeStr} ${tzName}`;
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
return date.toISOString();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Format cell content for display in popup dialog.
|
|
1239
|
+
* Handles JSON values, null values, JSON strings, date/time values, and regular text appropriately.
|
|
1240
|
+
* @param {*} value - The cell value to format
|
|
1241
|
+
* @returns {Object} Object with formatted content, isJson flag, isDateTime flag, and dateValue: { content: string, isJson: boolean, isDateTime: boolean, dateValue: Date | null }
|
|
1242
|
+
*/
|
|
1243
|
+
function formatCellContentForPopup(value, timezone = null) {
|
|
1244
|
+
if (value === null || value === undefined) {
|
|
1245
|
+
return { content: 'NULL', isJson: false, isDateTime: false, dateValue: null };
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const dateValue = isDateTimeValue(value);
|
|
1249
|
+
if (dateValue) {
|
|
1250
|
+
const tz = timezone || getCurrentTimezone();
|
|
1251
|
+
const formatted = formatDateTimeInTimezone(dateValue, tz);
|
|
1252
|
+
return { content: formatted, isJson: false, isDateTime: true, dateValue: dateValue };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Handle JSON objects/arrays
|
|
1256
|
+
if (isJsonValue(value)) {
|
|
1257
|
+
try {
|
|
1258
|
+
return { content: JSON.stringify(value, null, 2), isJson: true, isDateTime: false, dateValue: null };
|
|
1259
|
+
} catch (e) {
|
|
1260
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Handle string values - check if it's a JSON string
|
|
1265
|
+
if (typeof value === 'string') {
|
|
1266
|
+
const trimmed = value.trim();
|
|
1267
|
+
// Check if string looks like JSON (starts with { or [)
|
|
1268
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
1269
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
1270
|
+
try {
|
|
1271
|
+
const parsed = JSON.parse(trimmed);
|
|
1272
|
+
return { content: JSON.stringify(parsed, null, 2), isJson: true, isDateTime: false, dateValue: null };
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Show popup dialog with full cell content.
|
|
1284
|
+
* @param {string} column - Column name
|
|
1285
|
+
* @param {*} value - Cell value
|
|
1286
|
+
*/
|
|
1287
|
+
function showCellContentPopup(column, value) {
|
|
1288
|
+
closeCellContentPopup();
|
|
1289
|
+
|
|
1290
|
+
const overlay = document.createElement('div');
|
|
1291
|
+
overlay.className = 'cell-popup-overlay';
|
|
1292
|
+
overlay.addEventListener('click', (e) => {
|
|
1293
|
+
if (e.target === overlay) {
|
|
1294
|
+
closeCellContentPopup();
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const dialog = document.createElement('div');
|
|
1299
|
+
dialog.className = 'cell-popup-dialog';
|
|
1300
|
+
dialog.addEventListener('click', (e) => {
|
|
1301
|
+
e.stopPropagation();
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
const formatted = formatCellContentForPopup(value);
|
|
1305
|
+
const formattedContent = formatted.content;
|
|
1306
|
+
const isJson = formatted.isJson;
|
|
1307
|
+
const isDateTime = formatted.isDateTime;
|
|
1308
|
+
const dateValue = formatted.dateValue;
|
|
1309
|
+
|
|
1310
|
+
let currentTimezone = getCurrentTimezone();
|
|
1311
|
+
|
|
1312
|
+
const header = document.createElement('div');
|
|
1313
|
+
header.className = 'cell-popup-header';
|
|
1314
|
+
|
|
1315
|
+
const title = document.createElement('h3');
|
|
1316
|
+
title.className = 'cell-popup-title';
|
|
1317
|
+
title.textContent = column;
|
|
1318
|
+
header.appendChild(title);
|
|
1319
|
+
|
|
1320
|
+
const headerActions = document.createElement('div');
|
|
1321
|
+
headerActions.className = 'cell-popup-actions';
|
|
1322
|
+
|
|
1323
|
+
let timezoneSelect = null;
|
|
1324
|
+
if (isDateTime && dateValue) {
|
|
1325
|
+
timezoneSelect = document.createElement('select');
|
|
1326
|
+
timezoneSelect.className = 'cell-popup-timezone';
|
|
1327
|
+
timezoneSelect.title = 'Select timezone';
|
|
1328
|
+
|
|
1329
|
+
const timezones = getCommonTimezones();
|
|
1330
|
+
timezones.forEach(tz => {
|
|
1331
|
+
const option = document.createElement('option');
|
|
1332
|
+
option.value = tz.value;
|
|
1333
|
+
option.textContent = tz.label;
|
|
1334
|
+
if (tz.value === currentTimezone) {
|
|
1335
|
+
option.selected = true;
|
|
1336
|
+
}
|
|
1337
|
+
timezoneSelect.appendChild(option);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
headerActions.appendChild(timezoneSelect);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const copyButton = document.createElement('button');
|
|
1344
|
+
copyButton.className = 'cell-popup-copy';
|
|
1345
|
+
copyButton.innerHTML = '📋';
|
|
1346
|
+
copyButton.title = 'Copy to clipboard';
|
|
1347
|
+
|
|
1348
|
+
const updateContent = () => {
|
|
1349
|
+
if (value === null || value === undefined) {
|
|
1350
|
+
content.classList.add('null-content');
|
|
1351
|
+
content.classList.remove('json-value-popup', 'datetime-value-popup');
|
|
1352
|
+
content.textContent = 'NULL';
|
|
1353
|
+
} else if (isJson) {
|
|
1354
|
+
const formatted = formatCellContentForPopup(value, currentTimezone);
|
|
1355
|
+
content.classList.add('json-value-popup');
|
|
1356
|
+
content.classList.remove('null-content', 'datetime-value-popup');
|
|
1357
|
+
content.textContent = formatted.content;
|
|
1358
|
+
} else if (isDateTime && dateValue) {
|
|
1359
|
+
content.classList.add('datetime-value-popup');
|
|
1360
|
+
content.classList.remove('null-content', 'json-value-popup');
|
|
1361
|
+
|
|
1362
|
+
// Show multiple timezone formats
|
|
1363
|
+
const localTz = formatDateTimeInTimezone(dateValue, getCurrentTimezone());
|
|
1364
|
+
const utcTz = formatDateTimeInTimezone(dateValue, 'UTC');
|
|
1365
|
+
const selectedTz = formatDateTimeInTimezone(dateValue, currentTimezone);
|
|
1366
|
+
|
|
1367
|
+
let displayText = `Local (${getCurrentTimezone()}): ${localTz}\n`;
|
|
1368
|
+
displayText += `UTC: ${utcTz}\n`;
|
|
1369
|
+
if (currentTimezone !== getCurrentTimezone() && currentTimezone !== 'UTC') {
|
|
1370
|
+
displayText += `Selected (${currentTimezone}): ${selectedTz}`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
content.textContent = displayText;
|
|
1374
|
+
} else {
|
|
1375
|
+
const formatted = formatCellContentForPopup(value, currentTimezone);
|
|
1376
|
+
content.classList.remove('null-content', 'json-value-popup', 'datetime-value-popup');
|
|
1377
|
+
content.textContent = formatted.content;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Update copy button to use current formatted content
|
|
1381
|
+
const finalFormatted = formatCellContentForPopup(value, currentTimezone);
|
|
1382
|
+
copyButton._formattedContent = finalFormatted.content;
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
copyButton.addEventListener('click', async () => {
|
|
1386
|
+
try {
|
|
1387
|
+
const textToCopy = copyButton._formattedContent || formattedContent;
|
|
1388
|
+
await navigator.clipboard.writeText(textToCopy);
|
|
1389
|
+
copyButton.innerHTML = '✓';
|
|
1390
|
+
copyButton.title = 'Copied!';
|
|
1391
|
+
copyButton.classList.add('copied');
|
|
1392
|
+
setTimeout(() => {
|
|
1393
|
+
copyButton.innerHTML = '📋';
|
|
1394
|
+
copyButton.title = 'Copy to clipboard';
|
|
1395
|
+
copyButton.classList.remove('copied');
|
|
1396
|
+
}, 2000);
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
console.error('Failed to copy:', err);
|
|
1399
|
+
copyButton.innerHTML = '✗';
|
|
1400
|
+
copyButton.title = 'Copy failed';
|
|
1401
|
+
setTimeout(() => {
|
|
1402
|
+
copyButton.innerHTML = '📋';
|
|
1403
|
+
copyButton.title = 'Copy to clipboard';
|
|
1404
|
+
}, 2000);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
copyButton._formattedContent = formattedContent;
|
|
1408
|
+
headerActions.appendChild(copyButton);
|
|
1409
|
+
|
|
1410
|
+
const closeButton = document.createElement('button');
|
|
1411
|
+
closeButton.className = 'cell-popup-close';
|
|
1412
|
+
closeButton.innerHTML = '×';
|
|
1413
|
+
closeButton.title = 'Close';
|
|
1414
|
+
closeButton.addEventListener('click', closeCellContentPopup);
|
|
1415
|
+
headerActions.appendChild(closeButton);
|
|
1416
|
+
|
|
1417
|
+
header.appendChild(headerActions);
|
|
1418
|
+
|
|
1419
|
+
const body = document.createElement('div');
|
|
1420
|
+
body.className = 'cell-popup-body';
|
|
1421
|
+
|
|
1422
|
+
const content = document.createElement('pre');
|
|
1423
|
+
content.className = 'cell-popup-content';
|
|
1424
|
+
|
|
1425
|
+
updateContent();
|
|
1426
|
+
|
|
1427
|
+
if (timezoneSelect) {
|
|
1428
|
+
timezoneSelect.addEventListener('change', (e) => {
|
|
1429
|
+
currentTimezone = e.target.value;
|
|
1430
|
+
updateContent();
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
body.appendChild(content);
|
|
1435
|
+
|
|
1436
|
+
dialog.appendChild(header);
|
|
1437
|
+
dialog.appendChild(body);
|
|
1438
|
+
overlay.appendChild(dialog);
|
|
1439
|
+
|
|
1440
|
+
document.body.appendChild(overlay);
|
|
1441
|
+
|
|
1442
|
+
const escapeHandler = (e) => {
|
|
1443
|
+
if (e.key === 'Escape') {
|
|
1444
|
+
closeCellContentPopup();
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
overlay.dataset.escapeHandler = 'true';
|
|
1448
|
+
document.addEventListener('keydown', escapeHandler);
|
|
1449
|
+
|
|
1450
|
+
overlay._escapeHandler = escapeHandler;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Close the cell content popup dialog.
|
|
1455
|
+
*/
|
|
1456
|
+
function closeCellContentPopup() {
|
|
1457
|
+
const overlay = document.querySelector('.cell-popup-overlay');
|
|
1458
|
+
if (overlay) {
|
|
1459
|
+
if (overlay._escapeHandler) {
|
|
1460
|
+
document.removeEventListener('keydown', overlay._escapeHandler);
|
|
1461
|
+
}
|
|
1462
|
+
overlay.remove();
|
|
1463
|
+
}
|
|
1464
|
+
}
|
package/client/styles.css
CHANGED
|
@@ -526,11 +526,88 @@ th {
|
|
|
526
526
|
font-weight: var(--font-weight-semibold);
|
|
527
527
|
color: var(--text-primary);
|
|
528
528
|
border-bottom: 1px solid var(--border-subtle);
|
|
529
|
-
white-space:
|
|
529
|
+
white-space: normal;
|
|
530
530
|
font-size: var(--font-size-xs);
|
|
531
|
-
text-transform: uppercase;
|
|
532
531
|
letter-spacing: 0.05em;
|
|
533
532
|
position: relative;
|
|
533
|
+
vertical-align: bottom;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.column-header {
|
|
537
|
+
display: flex;
|
|
538
|
+
flex-direction: column;
|
|
539
|
+
gap: var(--space-1);
|
|
540
|
+
min-height: 100%;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.column-name-row {
|
|
544
|
+
display: flex;
|
|
545
|
+
align-items: center;
|
|
546
|
+
gap: var(--space-1);
|
|
547
|
+
line-height: 1.2;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.column-name {
|
|
551
|
+
line-height: 1.2;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.key-badges {
|
|
555
|
+
display: flex;
|
|
556
|
+
align-items: center;
|
|
557
|
+
gap: 2px;
|
|
558
|
+
flex-shrink: 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.key-badge {
|
|
562
|
+
display: inline-block;
|
|
563
|
+
padding: 2px 4px;
|
|
564
|
+
font-size: 9px;
|
|
565
|
+
font-weight: var(--font-weight-bold);
|
|
566
|
+
border-radius: 3px;
|
|
567
|
+
line-height: 1;
|
|
568
|
+
text-transform: uppercase;
|
|
569
|
+
letter-spacing: 0.05em;
|
|
570
|
+
white-space: nowrap;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.key-badge-pk {
|
|
574
|
+
background-color: #3b82f6;
|
|
575
|
+
color: #ffffff;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
[data-theme="dark"] .key-badge-pk {
|
|
579
|
+
background-color: #60a5fa;
|
|
580
|
+
color: #0f172a;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.key-badge-fk {
|
|
584
|
+
background-color: #f59e0b;
|
|
585
|
+
color: #ffffff;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
[data-theme="dark"] .key-badge-fk {
|
|
589
|
+
background-color: #fbbf24;
|
|
590
|
+
color: #0f172a;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
.key-badge-uq {
|
|
594
|
+
background-color: #10b981;
|
|
595
|
+
color: #ffffff;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
[data-theme="dark"] .key-badge-uq {
|
|
599
|
+
background-color: #34d399;
|
|
600
|
+
color: #0f172a;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.column-datatype {
|
|
604
|
+
font-size: 10px;
|
|
605
|
+
font-weight: var(--font-weight-normal);
|
|
606
|
+
color: var(--text-secondary);
|
|
607
|
+
text-transform: none;
|
|
608
|
+
letter-spacing: 0;
|
|
609
|
+
line-height: 1.2;
|
|
610
|
+
opacity: 0.8;
|
|
534
611
|
}
|
|
535
612
|
|
|
536
613
|
th.resizable {
|
|
@@ -591,9 +668,10 @@ td {
|
|
|
591
668
|
padding: var(--space-3) var(--space-4);
|
|
592
669
|
border-bottom: 1px solid var(--border-subtle);
|
|
593
670
|
color: var(--text-primary);
|
|
594
|
-
|
|
671
|
+
white-space: nowrap;
|
|
595
672
|
overflow: hidden;
|
|
596
673
|
text-overflow: ellipsis;
|
|
674
|
+
max-width: 200px;
|
|
597
675
|
}
|
|
598
676
|
|
|
599
677
|
tr:hover {
|
|
@@ -605,6 +683,198 @@ tr:hover {
|
|
|
605
683
|
font-style: italic;
|
|
606
684
|
}
|
|
607
685
|
|
|
686
|
+
.json-value {
|
|
687
|
+
margin: 0;
|
|
688
|
+
padding: 0;
|
|
689
|
+
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
|
690
|
+
"Courier New", monospace;
|
|
691
|
+
font-size: var(--font-size-xs);
|
|
692
|
+
line-height: 1.4;
|
|
693
|
+
color: var(--text-primary);
|
|
694
|
+
white-space: nowrap;
|
|
695
|
+
overflow: hidden;
|
|
696
|
+
text-overflow: ellipsis;
|
|
697
|
+
max-width: 100%;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* JSON value in popup - preserve formatting */
|
|
701
|
+
.json-value-popup {
|
|
702
|
+
white-space: pre-wrap;
|
|
703
|
+
word-break: break-word;
|
|
704
|
+
overflow-wrap: break-word;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* Cell Content Popup Dialog */
|
|
708
|
+
.cell-popup-overlay {
|
|
709
|
+
position: fixed;
|
|
710
|
+
top: 0;
|
|
711
|
+
left: 0;
|
|
712
|
+
right: 0;
|
|
713
|
+
bottom: 0;
|
|
714
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
715
|
+
z-index: 10000;
|
|
716
|
+
display: flex;
|
|
717
|
+
align-items: center;
|
|
718
|
+
justify-content: center;
|
|
719
|
+
padding: var(--space-4);
|
|
720
|
+
animation: fadeIn var(--transition-base);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
[data-theme="dark"] .cell-popup-overlay {
|
|
724
|
+
background-color: rgba(0, 0, 0, 0.7);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
@keyframes fadeIn {
|
|
728
|
+
from {
|
|
729
|
+
opacity: 0;
|
|
730
|
+
}
|
|
731
|
+
to {
|
|
732
|
+
opacity: 1;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.cell-popup-dialog {
|
|
737
|
+
background-color: var(--bg-base);
|
|
738
|
+
border-radius: var(--radius-lg);
|
|
739
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
740
|
+
max-width: 90vw;
|
|
741
|
+
max-height: 90vh;
|
|
742
|
+
width: 600px;
|
|
743
|
+
display: flex;
|
|
744
|
+
flex-direction: column;
|
|
745
|
+
animation: slideUp var(--transition-base);
|
|
746
|
+
border: 1px solid var(--border-subtle);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
[data-theme="dark"] .cell-popup-dialog {
|
|
750
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
@keyframes slideUp {
|
|
754
|
+
from {
|
|
755
|
+
transform: translateY(20px);
|
|
756
|
+
opacity: 0;
|
|
757
|
+
}
|
|
758
|
+
to {
|
|
759
|
+
transform: translateY(0);
|
|
760
|
+
opacity: 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
.cell-popup-header {
|
|
765
|
+
padding: var(--space-4) var(--space-6);
|
|
766
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
767
|
+
display: flex;
|
|
768
|
+
align-items: center;
|
|
769
|
+
justify-content: space-between;
|
|
770
|
+
background-color: var(--bg-elevated);
|
|
771
|
+
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.cell-popup-title {
|
|
775
|
+
font-size: var(--font-size-lg);
|
|
776
|
+
font-weight: var(--font-weight-semibold);
|
|
777
|
+
color: var(--text-primary);
|
|
778
|
+
margin: 0;
|
|
779
|
+
flex: 1;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.cell-popup-actions {
|
|
783
|
+
display: flex;
|
|
784
|
+
align-items: center;
|
|
785
|
+
gap: var(--space-2);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.cell-popup-timezone {
|
|
789
|
+
padding: var(--space-2) var(--space-3);
|
|
790
|
+
font-size: var(--font-size-sm);
|
|
791
|
+
font-family: inherit;
|
|
792
|
+
color: var(--text-primary);
|
|
793
|
+
background-color: var(--bg-base);
|
|
794
|
+
border: 1px solid var(--border-subtle);
|
|
795
|
+
border-radius: var(--radius-sm);
|
|
796
|
+
cursor: pointer;
|
|
797
|
+
transition: all var(--transition-fast);
|
|
798
|
+
min-width: 200px;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.cell-popup-timezone:hover {
|
|
802
|
+
border-color: var(--border-default);
|
|
803
|
+
background-color: var(--bg-hover);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.cell-popup-timezone:focus {
|
|
807
|
+
outline: none;
|
|
808
|
+
border-color: var(--color-primary);
|
|
809
|
+
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.cell-popup-copy,
|
|
813
|
+
.cell-popup-close {
|
|
814
|
+
background: none;
|
|
815
|
+
border: none;
|
|
816
|
+
cursor: pointer;
|
|
817
|
+
padding: var(--space-2);
|
|
818
|
+
color: var(--text-secondary);
|
|
819
|
+
font-size: var(--font-size-xl);
|
|
820
|
+
line-height: 1;
|
|
821
|
+
border-radius: var(--radius-sm);
|
|
822
|
+
transition: all var(--transition-fast);
|
|
823
|
+
display: flex;
|
|
824
|
+
align-items: center;
|
|
825
|
+
justify-content: center;
|
|
826
|
+
width: 32px;
|
|
827
|
+
height: 32px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.cell-popup-copy:hover,
|
|
831
|
+
.cell-popup-close:hover {
|
|
832
|
+
background-color: var(--bg-hover);
|
|
833
|
+
color: var(--text-primary);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.cell-popup-copy.copied {
|
|
837
|
+
color: var(--color-success);
|
|
838
|
+
background-color: var(--bg-hover);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.cell-popup-body {
|
|
842
|
+
padding: var(--space-6);
|
|
843
|
+
overflow-y: auto;
|
|
844
|
+
flex: 1;
|
|
845
|
+
min-height: 0;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
.cell-popup-content {
|
|
849
|
+
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
|
850
|
+
"Courier New", monospace;
|
|
851
|
+
font-size: var(--font-size-sm);
|
|
852
|
+
line-height: 1.6;
|
|
853
|
+
color: var(--text-primary);
|
|
854
|
+
white-space: pre-wrap;
|
|
855
|
+
word-break: break-word;
|
|
856
|
+
overflow-wrap: break-word;
|
|
857
|
+
background-color: var(--bg-elevated);
|
|
858
|
+
padding: var(--space-4);
|
|
859
|
+
border-radius: var(--radius-md);
|
|
860
|
+
border: 1px solid var(--border-subtle);
|
|
861
|
+
max-height: 60vh;
|
|
862
|
+
overflow-y: auto;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
.cell-popup-content.null-content {
|
|
866
|
+
color: var(--text-tertiary);
|
|
867
|
+
font-style: italic;
|
|
868
|
+
font-family: inherit;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.cell-popup-content.datetime-value-popup {
|
|
872
|
+
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
|
873
|
+
"Courier New", monospace;
|
|
874
|
+
line-height: 1.8;
|
|
875
|
+
white-space: pre-wrap;
|
|
876
|
+
}
|
|
877
|
+
|
|
608
878
|
.column-selector {
|
|
609
879
|
position: relative;
|
|
610
880
|
}
|
|
@@ -632,6 +902,33 @@ tr:hover {
|
|
|
632
902
|
color: var(--text-primary);
|
|
633
903
|
border-bottom: 1px solid var(--border-subtle);
|
|
634
904
|
background-color: var(--bg-elevated);
|
|
905
|
+
display: flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
justify-content: space-between;
|
|
908
|
+
gap: var(--space-3);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.column-menu-header-title {
|
|
912
|
+
flex: 1;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.column-select-all-button {
|
|
916
|
+
padding: var(--space-1) var(--space-3);
|
|
917
|
+
font-size: var(--font-size-xs);
|
|
918
|
+
font-weight: var(--font-weight-medium);
|
|
919
|
+
color: var(--color-primary);
|
|
920
|
+
background-color: transparent;
|
|
921
|
+
border: 1px solid var(--border-subtle);
|
|
922
|
+
border-radius: var(--radius-sm);
|
|
923
|
+
cursor: pointer;
|
|
924
|
+
transition: all var(--transition-fast);
|
|
925
|
+
white-space: nowrap;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.column-select-all-button:hover {
|
|
929
|
+
background-color: var(--bg-hover);
|
|
930
|
+
border-color: var(--color-primary);
|
|
931
|
+
color: var(--color-primary);
|
|
635
932
|
}
|
|
636
933
|
|
|
637
934
|
.column-menu-options {
|
package/package.json
CHANGED
package/pglens-1.1.0.tgz
ADDED
|
Binary file
|
package/src/routes/api.js
CHANGED
|
@@ -88,6 +88,147 @@ async function getPrimaryKeyColumn(pool, tableName) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Get foreign key relationships for a table.
|
|
93
|
+
* Queries information_schema to get foreign key constraints and their references.
|
|
94
|
+
* @param {Pool} pool - Database connection pool
|
|
95
|
+
* @param {string} tableName - Name of the table
|
|
96
|
+
* @returns {Promise<Object>} Object mapping column names to their foreign key references { table, column }
|
|
97
|
+
*/
|
|
98
|
+
async function getForeignKeyRelations(pool, tableName) {
|
|
99
|
+
try {
|
|
100
|
+
const fkQuery = `
|
|
101
|
+
SELECT
|
|
102
|
+
kcu.column_name,
|
|
103
|
+
ccu.table_name AS foreign_table_name,
|
|
104
|
+
ccu.column_name AS foreign_column_name
|
|
105
|
+
FROM information_schema.table_constraints AS tc
|
|
106
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
107
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
108
|
+
AND tc.table_schema = kcu.table_schema
|
|
109
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
110
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
111
|
+
AND ccu.table_schema = tc.table_schema
|
|
112
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
113
|
+
AND tc.table_name = $1
|
|
114
|
+
AND tc.table_schema = 'public';
|
|
115
|
+
`;
|
|
116
|
+
const result = await pool.query(fkQuery, [tableName]);
|
|
117
|
+
const foreignKeys = {};
|
|
118
|
+
result.rows.forEach(row => {
|
|
119
|
+
foreignKeys[row.column_name] = {
|
|
120
|
+
table: row.foreign_table_name,
|
|
121
|
+
column: row.foreign_column_name
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
return foreignKeys;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('Error getting foreign key relations:', error);
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get all primary key columns for a table.
|
|
133
|
+
* @param {Pool} pool - Database connection pool
|
|
134
|
+
* @param {string} tableName - Name of the table
|
|
135
|
+
* @returns {Promise<Set>} Set of primary key column names
|
|
136
|
+
*/
|
|
137
|
+
async function getPrimaryKeyColumns(pool, tableName) {
|
|
138
|
+
try {
|
|
139
|
+
const pkQuery = `
|
|
140
|
+
SELECT kcu.column_name
|
|
141
|
+
FROM information_schema.table_constraints tc
|
|
142
|
+
JOIN information_schema.key_column_usage kcu
|
|
143
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
144
|
+
AND tc.table_schema = kcu.table_schema
|
|
145
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
146
|
+
AND tc.table_name = $1
|
|
147
|
+
AND tc.table_schema = 'public';
|
|
148
|
+
`;
|
|
149
|
+
const result = await pool.query(pkQuery, [tableName]);
|
|
150
|
+
const pkColumns = new Set();
|
|
151
|
+
result.rows.forEach(row => {
|
|
152
|
+
pkColumns.add(row.column_name);
|
|
153
|
+
});
|
|
154
|
+
return pkColumns;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error('Error getting primary key columns:', error);
|
|
157
|
+
return new Set();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get all unique constraint columns for a table.
|
|
163
|
+
* @param {Pool} pool - Database connection pool
|
|
164
|
+
* @param {string} tableName - Name of the table
|
|
165
|
+
* @returns {Promise<Set>} Set of unique constraint column names
|
|
166
|
+
*/
|
|
167
|
+
async function getUniqueColumns(pool, tableName) {
|
|
168
|
+
try {
|
|
169
|
+
const uniqueQuery = `
|
|
170
|
+
SELECT kcu.column_name
|
|
171
|
+
FROM information_schema.table_constraints tc
|
|
172
|
+
JOIN information_schema.key_column_usage kcu
|
|
173
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
174
|
+
AND tc.table_schema = kcu.table_schema
|
|
175
|
+
WHERE tc.constraint_type = 'UNIQUE'
|
|
176
|
+
AND tc.table_name = $1
|
|
177
|
+
AND tc.table_schema = 'public';
|
|
178
|
+
`;
|
|
179
|
+
const result = await pool.query(uniqueQuery, [tableName]);
|
|
180
|
+
const uniqueColumns = new Set();
|
|
181
|
+
result.rows.forEach(row => {
|
|
182
|
+
uniqueColumns.add(row.column_name);
|
|
183
|
+
});
|
|
184
|
+
return uniqueColumns;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error('Error getting unique columns:', error);
|
|
187
|
+
return new Set();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get column metadata (datatypes and key relationships) for a table.
|
|
193
|
+
* Queries information_schema.columns to get column names and their data types,
|
|
194
|
+
* and includes key relationship information (primary keys, foreign keys, unique constraints).
|
|
195
|
+
* @param {Pool} pool - Database connection pool
|
|
196
|
+
* @param {string} tableName - Name of the table
|
|
197
|
+
* @returns {Promise<Object>} Object mapping column names to metadata objects with dataType, isPrimaryKey, isForeignKey, foreignKeyRef, isUnique
|
|
198
|
+
*/
|
|
199
|
+
async function getColumnMetadata(pool, tableName) {
|
|
200
|
+
try {
|
|
201
|
+
const metadataQuery = `
|
|
202
|
+
SELECT column_name, data_type
|
|
203
|
+
FROM information_schema.columns
|
|
204
|
+
WHERE table_schema = 'public'
|
|
205
|
+
AND table_name = $1
|
|
206
|
+
ORDER BY ordinal_position;
|
|
207
|
+
`;
|
|
208
|
+
const result = await pool.query(metadataQuery, [tableName]);
|
|
209
|
+
|
|
210
|
+
const primaryKeyColumns = await getPrimaryKeyColumns(pool, tableName);
|
|
211
|
+
const foreignKeyRelations = await getForeignKeyRelations(pool, tableName);
|
|
212
|
+
const uniqueColumns = await getUniqueColumns(pool, tableName);
|
|
213
|
+
|
|
214
|
+
const columns = {};
|
|
215
|
+
result.rows.forEach(row => {
|
|
216
|
+
const columnName = row.column_name;
|
|
217
|
+
columns[columnName] = {
|
|
218
|
+
dataType: row.data_type,
|
|
219
|
+
isPrimaryKey: primaryKeyColumns.has(columnName),
|
|
220
|
+
isForeignKey: !!foreignKeyRelations[columnName],
|
|
221
|
+
foreignKeyRef: foreignKeyRelations[columnName] || null,
|
|
222
|
+
isUnique: uniqueColumns.has(columnName)
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
return columns;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('Error getting column metadata:', error);
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
91
232
|
/**
|
|
92
233
|
* GET /api/tables/:tableName
|
|
93
234
|
*
|
|
@@ -110,7 +251,13 @@ async function getPrimaryKeyColumn(pool, tableName) {
|
|
|
110
251
|
* limit: number,
|
|
111
252
|
* isApproximate: boolean,
|
|
112
253
|
* nextCursor: string|null,
|
|
113
|
-
* hasPrimaryKey: boolean
|
|
254
|
+
* hasPrimaryKey: boolean,
|
|
255
|
+
* columns: Object - Map of column names to metadata objects with:
|
|
256
|
+
* - dataType: string
|
|
257
|
+
* - isPrimaryKey: boolean
|
|
258
|
+
* - isForeignKey: boolean
|
|
259
|
+
* - foreignKeyRef: { table: string, column: string } | null
|
|
260
|
+
* - isUnique: boolean
|
|
114
261
|
* }
|
|
115
262
|
*/
|
|
116
263
|
router.get('/tables/:tableName', async (req, res) => {
|
|
@@ -126,6 +273,7 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
126
273
|
|
|
127
274
|
const pool = getPool();
|
|
128
275
|
const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
|
|
276
|
+
const columnMetadata = await getColumnMetadata(pool, tableName);
|
|
129
277
|
|
|
130
278
|
const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
|
|
131
279
|
const countResult = await pool.query(countQuery);
|
|
@@ -188,6 +336,7 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
188
336
|
isApproximate,
|
|
189
337
|
nextCursor,
|
|
190
338
|
hasPrimaryKey: !!primaryKeyColumn,
|
|
339
|
+
columns: columnMetadata,
|
|
191
340
|
};
|
|
192
341
|
res.json(responseData);
|
|
193
342
|
} catch (error) {
|
package/pglens-1.0.0.tgz
DELETED
|
Binary file
|