@hienlh/ppm 0.13.9 → 0.13.11
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 +8 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +2135 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/ai-settings-section-ysK_Eixc.js +1 -0
- package/dist/web/assets/architecture-PBZL5I3N-By4Nv3Gj.js +1 -0
- package/dist/web/assets/{audio-preview-C1p-Q5XZ.js → audio-preview-dOvnFQN8.js} +1 -1
- package/dist/web/assets/chat-tab-C6NVtLx3.js +12 -0
- package/dist/web/assets/code-editor-3e9UAnzv.js +8 -0
- package/dist/web/assets/{conflict-editor-Dcn3HuLD.js → conflict-editor-XMpuwtUv.js} +1 -1
- package/dist/web/assets/csv-parser-Dly5nqE1.js +6 -0
- package/dist/web/assets/{csv-preview-D5lmgVEy.js → csv-preview-7TsYBQI6.js} +3 -3
- package/dist/web/assets/data-grid-overlay-editor-BjjuE4-G.js +1 -0
- package/dist/web/assets/data-grid-types-BTQHYBUh.js +1 -0
- package/dist/web/assets/database-viewer-GNKYeCIc.js +1 -0
- package/dist/web/assets/{diff-viewer-NMLD4V8q.js → diff-viewer-DjFe6idy.js} +1 -1
- package/dist/web/assets/dist-0kPgRaVx.js +1 -0
- package/dist/web/assets/{esm-nXReYVnB.js → esm-zjerHxpO.js} +1 -1
- package/dist/web/assets/{extension-webview-DW2dBswj.js → extension-webview-hgbO93RZ.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BLXEKVf1.js +1 -0
- package/dist/web/assets/glide-data-grid-nthEL3fk.css +1 -0
- package/dist/web/assets/glide-data-grid-uLRTmkwH.js +136 -0
- package/dist/web/assets/{image-preview-Dqp1KSus.js → image-preview-CzwOU5op.js} +1 -1
- package/dist/web/assets/index-BAPR3hYQ.js +27 -0
- package/dist/web/assets/index-COOnLKGB.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-CEkPcChg.js +1 -0
- package/dist/web/assets/{input-DYWhyaze.js → input-ozrR2DAV.js} +1 -1
- package/dist/web/assets/keybindings-store-D9GV2h8K.js +1 -0
- package/dist/web/assets/{markdown-renderer-DNIXdY0d.js → markdown-renderer-BD5P19YN.js} +3 -3
- package/dist/web/assets/number-overlay-editor-BoRxunFN.js +9 -0
- package/dist/web/assets/packet-RMMSAZCW-DECxYTOi.js +1 -0
- package/dist/web/assets/{pdf-preview-ChC1gaaZ.js → pdf-preview-DRbBRYIF.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-cjpNfVG5.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-dLhH_g2l.js → port-forwarding-tab-BSFhNv-A.js} +1 -1
- package/dist/web/assets/{postgres-viewer-De0pzd1C.js → postgres-viewer-D51L9fxD.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-Dnpi068b.js +1 -0
- package/dist/web/assets/{settings-store-BHBb62gq.js → settings-store-B-OmHo3J.js} +1 -1
- package/dist/web/assets/settings-tab-DdbYEmAC.js +1 -0
- package/dist/web/assets/sql-query-editor-7pD60nKZ.js +3 -0
- package/dist/web/assets/sqlite-viewer-DLNJ_IGM.js +1 -0
- package/dist/web/assets/terminal-tab-6T5e8Nar.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-DRyb1eiw.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CP-vyTF8.js → use-monaco-theme-DgzxiZS5.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CMiurk2b.js → vendor-mermaid-CCmA_6Y0.js} +3 -3
- package/dist/web/assets/{video-preview-CHPVrMtx.js → video-preview-BwDNFl7v.js} +1 -1
- package/dist/web/assets/x-CG-_0yIW.js +1 -0
- package/dist/web/index.html +10 -11
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +0 -0
- package/src/web/components/chat/chat-tab.tsx +4 -1
- package/src/web/components/chat/message-input.tsx +28 -14
- package/src/web/components/database/database-viewer.tsx +19 -8
- package/src/web/components/database/export-button.tsx +38 -18
- package/src/web/components/database/glide-column-search.tsx +81 -0
- package/src/web/components/database/glide-context-menu.tsx +95 -0
- package/src/web/components/database/glide-data-grid.tsx +207 -0
- package/src/web/components/database/glide-data-preview-panel.tsx +113 -0
- package/src/web/components/database/glide-grid-pagination.tsx +34 -0
- package/src/web/components/database/glide-grid-toolbar.tsx +105 -0
- package/src/web/components/database/glide-grid-types.ts +2 -0
- package/src/web/components/database/glide-header-menu.tsx +111 -0
- package/src/web/components/database/glide-save-bar.tsx +33 -0
- package/src/web/components/database/sql-query-editor.tsx +14 -4
- package/src/web/components/database/use-database.ts +10 -2
- package/src/web/components/database/use-glide-cell-content.ts +65 -30
- package/src/web/components/database/use-glide-columns.ts +24 -16
- package/src/web/components/database/use-glide-grid-actions.ts +164 -0
- package/src/web/components/database/use-glide-pending-edits.ts +72 -0
- package/src/web/components/database/use-glide-row-pinning.ts +35 -0
- package/src/web/components/editor/code-editor.tsx +7 -5
- package/src/web/components/layout/editor-panel.tsx +2 -2
- package/src/web/components/sqlite/sqlite-viewer.tsx +21 -12
- package/src/web/components/sqlite/use-sqlite.ts +1 -1
- package/src/web/hooks/use-chat.ts +27 -20
- package/src/web/hooks/use-global-keybindings.ts +12 -0
- package/src/web/hooks/use-terminal.ts +1 -1
- package/dist/web/assets/ai-settings-section-CLNBWLS4.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-WMbLpD5Y.js +0 -1
- package/dist/web/assets/chat-tab-BSJUkgxB.js +0 -12
- package/dist/web/assets/code-editor-rNw5_pXh.js +0 -8
- package/dist/web/assets/csv-parser-DO0dz4x_.js +0 -6
- package/dist/web/assets/data-grid-nZfSIop5.js +0 -5
- package/dist/web/assets/database-viewer-CNoq5Uxp.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BdPTuzO3.js +0 -1
- package/dist/web/assets/index-CoMWx5VS.js +0 -27
- package/dist/web/assets/index-Dzb3OtrX.css +0 -2
- package/dist/web/assets/info-3K5VOQVL-MHX_1JfR.js +0 -1
- package/dist/web/assets/keybindings-store-B7nlHmDh.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-CreFbf9A.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-CnaHXUh8.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-UxsdRHvt.js +0 -1
- package/dist/web/assets/settings-tab-Mrs9uzCZ.js +0 -1
- package/dist/web/assets/sql-completion-provider-tCzZfqWs.js +0 -1
- package/dist/web/assets/sql-query-editor-CMQpaOjA.js +0 -3
- package/dist/web/assets/sqlite-viewer-BqtIjvil.js +0 -1
- package/dist/web/assets/terminal-tab-CeHEtoE2.js +0 -1
- package/dist/web/assets/trash-2-CJYoLw7Q.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CBVPi4NV.js +0 -1
- package/dist/web/assets/x-OGGXhtlb.js +0 -1
- /package/dist/web/assets/{arrow-up-Dtrfv490.js → arrow-up-Rcw6_KKu.js} +0 -0
- /package/dist/web/assets/{dist-D7KGU7Vl.js → dist-CaKCIxem.js} +0 -0
- /package/dist/web/assets/{dist-CGvx1c8C.js → dist-DGSkE2Ml.js} +0 -0
- /package/dist/web/assets/{katex-BFE6i_OH.js → katex-BuytEdO1.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-DQHnkzGy.js} +0 -0
- /package/dist/web/assets/{refresh-cw-CSFrDtiu.js → refresh-cw-LlbZDJpO.js} +0 -0
- /package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-7H-Q_k8c.js} +0 -0
- /package/dist/web/assets/{sparkles-B0mRBy_j.js → sparkles-fWUT5Vzq.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
- /package/dist/web/assets/{use-blob-url-Hn6n1730.js → use-blob-url-e9uTXjv5.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-u3AZMvTx.js → vendor-xterm-CU2c3f0A.js} +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
|
+
import DataEditor, { type GridColumn, type Item } from "@glideapps/glide-data-grid";
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
import type { GlideGridProps } from "./glide-grid-types";
|
|
5
|
+
import { useGlideTheme } from "./glide-grid-theme";
|
|
6
|
+
import { useGlideColumns } from "./use-glide-columns";
|
|
7
|
+
import { useGlideCellContent } from "./use-glide-cell-content";
|
|
8
|
+
import { useGlideSelection } from "./use-glide-selection";
|
|
9
|
+
import { useGlidePendingEdits } from "./use-glide-pending-edits";
|
|
10
|
+
import { useGlideRowPinning } from "./use-glide-row-pinning";
|
|
11
|
+
import { useGlideGridActions } from "./use-glide-grid-actions";
|
|
12
|
+
import { GlideHeaderMenu } from "./glide-header-menu";
|
|
13
|
+
import { GlideContextMenu } from "./glide-context-menu";
|
|
14
|
+
import { GlideGridToolbar } from "./glide-grid-toolbar";
|
|
15
|
+
import { GlideSaveBar } from "./glide-save-bar";
|
|
16
|
+
import { GlideGridPagination } from "./glide-grid-pagination";
|
|
17
|
+
import { GlideDataPreviewPanel } from "./glide-data-preview-panel";
|
|
18
|
+
|
|
19
|
+
const HEADER_ICONS: Record<string, (p: { fgColor: string }) => string> = {
|
|
20
|
+
sortAsc: (p) => `<svg viewBox="0 0 16 16" fill="${p.fgColor}"><path d="M8 4l4 6H4z"/></svg>`,
|
|
21
|
+
sortDesc: (p) => `<svg viewBox="0 0 16 16" fill="${p.fgColor}"><path d="M8 12l4-6H4z"/></svg>`,
|
|
22
|
+
headerFk: () => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round"><path d="M6 4.5H4.5a2.5 2.5 0 000 5H6M10 4.5h1.5a2.5 2.5 0 010 5H10M5 7h6"/></svg>`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Glide Data Grid wrapper for PPM database viewers.
|
|
27
|
+
* Shared by database-viewer (Postgres/MySQL) and sqlite-viewer.
|
|
28
|
+
*/
|
|
29
|
+
export function GlideDataGrid(props: GlideGridProps) {
|
|
30
|
+
const {
|
|
31
|
+
columns: rawColumnNames, rows, total, limit, schema, loading,
|
|
32
|
+
page, onPageChange, onCellUpdate, onRowDelete, onBulkDelete, onInsertRow,
|
|
33
|
+
orderBy, orderDir, onToggleSort, onClearSort, columnFilters = {}, onColumnFilter,
|
|
34
|
+
connectionId, selectedTable, selectedSchema, connectionName,
|
|
35
|
+
} = props;
|
|
36
|
+
|
|
37
|
+
const theme = useGlideTheme();
|
|
38
|
+
const gridRef = useRef<any>(null);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
|
|
41
|
+
const columnNames = useMemo(() => {
|
|
42
|
+
const names = new Set(schema.map((s) => s.name));
|
|
43
|
+
return rawColumnNames.filter((c) => names.has(c));
|
|
44
|
+
}, [rawColumnNames, schema]);
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
const [pinnedCols, setPinnedCols] = useState<Set<string>>(new Set());
|
|
48
|
+
const [colWidths, setColWidths] = useState<Map<string, number>>(new Map());
|
|
49
|
+
const [headerMenu, setHeaderMenu] = useState<{ colName: string; bounds: { x: number; y: number; width: number; height: number } } | null>(null);
|
|
50
|
+
const [contextMenu, setContextMenu] = useState<{ position: { x: number; y: number }; rowIdx: number; colIdx: number } | null>(null);
|
|
51
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
52
|
+
const [insertedRows, setInsertedRows] = useState<Record<string, unknown>[]>([]);
|
|
53
|
+
|
|
54
|
+
const pkCol = useMemo(() => {
|
|
55
|
+
return schema.find((c) => c.pk)?.name ?? schema.find((c) => c.name.toLowerCase() === "id")?.name ?? null;
|
|
56
|
+
}, [schema]);
|
|
57
|
+
|
|
58
|
+
// Row pinning (pinned rows frozen at bottom via freezeTrailingRows)
|
|
59
|
+
const { effectiveRows, pinnedCount, pinnedPks, setPinnedPks } = useGlideRowPinning(rows, pkCol);
|
|
60
|
+
|
|
61
|
+
// Merge rows: unpinned → inserted → pinned (pinned must be last for freezeTrailingRows)
|
|
62
|
+
const allRows = useMemo(() => {
|
|
63
|
+
if (pinnedCount === 0) return [...effectiveRows, ...insertedRows];
|
|
64
|
+
const unpinned = effectiveRows.slice(0, effectiveRows.length - pinnedCount);
|
|
65
|
+
const pinned = effectiveRows.slice(effectiveRows.length - pinnedCount);
|
|
66
|
+
return [...unpinned, ...insertedRows, ...pinned];
|
|
67
|
+
}, [effectiveRows, insertedRows, pinnedCount]);
|
|
68
|
+
const displayRows = useMemo(() => {
|
|
69
|
+
if (!searchTerm.trim()) return allRows;
|
|
70
|
+
const term = searchTerm.toLowerCase();
|
|
71
|
+
return allRows.filter((row) => columnNames.some((col) => String(row[col] ?? "").toLowerCase().includes(term)));
|
|
72
|
+
}, [allRows, columnNames, searchTerm]);
|
|
73
|
+
|
|
74
|
+
const frozenTrailingRows = useMemo(() => {
|
|
75
|
+
if (pinnedCount === 0 || !pkCol) return 0;
|
|
76
|
+
if (!searchTerm.trim()) return pinnedCount;
|
|
77
|
+
return displayRows.filter((row) => pinnedPks.has(String(row[pkCol] ?? ""))).length;
|
|
78
|
+
}, [pinnedCount, pkCol, searchTerm, displayRows, pinnedPks]);
|
|
79
|
+
|
|
80
|
+
// Hooks
|
|
81
|
+
const { pendingRef, addEdit, commitAll, discardAll, hasPending, pendingCount, committedRef } = useGlidePendingEdits(pkCol, onCellUpdate, onInsertRow);
|
|
82
|
+
const { columns, freezeColumns, columnOrder } = useGlideColumns(schema, columnNames, pinnedCols, colWidths, displayRows, orderBy, orderDir);
|
|
83
|
+
const { getCellContent, onCellEdited } = useGlideCellContent(displayRows, columnOrder, schema, pkCol, addEdit, pendingRef);
|
|
84
|
+
const { gridSelection, onGridSelectionChange, selectedRowIndices, clearSelection } = useGlideSelection();
|
|
85
|
+
const {
|
|
86
|
+
previewData, setPreviewData, openRowPreview, openCellPreview, openPreviewInTab,
|
|
87
|
+
handlePaste, getContextFk, isCellViewable, openFkTable,
|
|
88
|
+
} = useGlideGridActions({ displayRows, columnOrder, schema, pkCol, connectionId, connectionName, selectedTable, selectedSchema, addEdit, gridSelection, containerRef });
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (committedRef.current) { committedRef.current = false; discardAll(); setInsertedRows([]); }
|
|
92
|
+
}, [rows]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
|
+
|
|
94
|
+
const handleColumnResize = useCallback((col: GridColumn, newSize: number) => {
|
|
95
|
+
setColWidths((prev) => new Map(prev).set(col.id ?? col.title, newSize));
|
|
96
|
+
}, []);
|
|
97
|
+
const handleHeaderMenuClick = useCallback((colIdx: number, bounds: { x: number; y: number; width: number; height: number }) => {
|
|
98
|
+
const colName = columnOrder[colIdx]; if (colName) setHeaderMenu({ colName, bounds });
|
|
99
|
+
}, [columnOrder]);
|
|
100
|
+
const handleCellContextMenu = useCallback(([colIdx, rowIdx]: Item, event: { preventDefault: () => void; localEventX: number; localEventY: number; bounds: { x: number; y: number } }) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
setContextMenu({ position: { x: event.bounds.x + event.localEventX, y: event.bounds.y + event.localEventY }, rowIdx, colIdx });
|
|
103
|
+
}, []);
|
|
104
|
+
const togglePinColumn = useCallback((colName: string) => {
|
|
105
|
+
setPinnedCols((prev) => { const n = new Set(prev); if (n.has(colName)) n.delete(colName); else n.add(colName); return n; });
|
|
106
|
+
}, []);
|
|
107
|
+
const togglePinRow = useCallback((rowIdx: number) => {
|
|
108
|
+
if (!pkCol) return;
|
|
109
|
+
const row = displayRows[rowIdx]; if (!row) return;
|
|
110
|
+
const pk = String(row[pkCol] ?? "");
|
|
111
|
+
setPinnedPks((prev) => { const n = new Set(prev); if (n.has(pk)) n.delete(pk); else n.add(pk); return n; });
|
|
112
|
+
}, [pkCol, displayRows, setPinnedPks]);
|
|
113
|
+
const getRowThemeOverride = useCallback((rowIdx: number) => {
|
|
114
|
+
if (pinnedPks.size === 0 || !pkCol) return undefined;
|
|
115
|
+
const row = displayRows[rowIdx];
|
|
116
|
+
if (row && pinnedPks.has(String(row[pkCol] ?? ""))) return { bgCell: theme.bgCellMedium ?? theme.bgHeader };
|
|
117
|
+
return undefined;
|
|
118
|
+
}, [pinnedPks, pkCol, displayRows, theme]);
|
|
119
|
+
const handleBulkDelete = useCallback(() => {
|
|
120
|
+
if (!pkCol || !onBulkDelete) return;
|
|
121
|
+
onBulkDelete(pkCol, selectedRowIndices.map((i) => displayRows[i]?.[pkCol]).filter((v) => v != null));
|
|
122
|
+
clearSelection();
|
|
123
|
+
}, [pkCol, onBulkDelete, selectedRowIndices, displayRows, clearSelection]);
|
|
124
|
+
|
|
125
|
+
const [colSearchOpen, setColSearchOpen] = useState(false);
|
|
126
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
127
|
+
if (e.key === "Escape") { setPreviewData(null); setColSearchOpen(false); return; }
|
|
128
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && hasPending) { e.preventDefault(); commitAll(); }
|
|
129
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
130
|
+
if (e.key === "/" && tag !== "INPUT" && tag !== "TEXTAREA") { e.preventDefault(); setColSearchOpen(true); }
|
|
131
|
+
}, [hasPending, commitAll]);
|
|
132
|
+
const handleRowAppended = useCallback(() => {
|
|
133
|
+
if (!pkCol || !onInsertRow) return;
|
|
134
|
+
setInsertedRows((prev) => [...prev, { [pkCol]: `__new_${Date.now()}` }]);
|
|
135
|
+
}, [pkCol, onInsertRow]);
|
|
136
|
+
const handleColumnJump = useCallback((colName: string) => {
|
|
137
|
+
const idx = columnOrder.indexOf(colName);
|
|
138
|
+
if (idx >= 0 && gridRef.current?.scrollTo) gridRef.current.scrollTo(idx, 0);
|
|
139
|
+
}, [columnOrder]);
|
|
140
|
+
|
|
141
|
+
// Context menu derived state
|
|
142
|
+
const contextRow = contextMenu ? displayRows[contextMenu.rowIdx] : null;
|
|
143
|
+
const contextPk = contextRow && pkCol ? String(contextRow[pkCol] ?? "") : "";
|
|
144
|
+
const contextColName = contextMenu ? columnOrder[contextMenu.colIdx] : null;
|
|
145
|
+
const contextCellViewable = isCellViewable(contextRow ?? null, contextColName ?? null);
|
|
146
|
+
const contextFk = getContextFk(contextColName ?? null);
|
|
147
|
+
const contextCellValue = contextRow && contextColName ? contextRow[contextColName] : null;
|
|
148
|
+
|
|
149
|
+
if (!columnNames.length) {
|
|
150
|
+
return <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
|
151
|
+
{loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
|
|
152
|
+
</div>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div ref={containerRef} className="flex flex-col h-full overflow-hidden relative" tabIndex={0} onKeyDown={handleKeyDown}>
|
|
157
|
+
<GlideGridToolbar hasSelection={selectedRowIndices.length > 0} selectedCount={selectedRowIndices.length}
|
|
158
|
+
onBulkDelete={onBulkDelete && pkCol ? handleBulkDelete : undefined}
|
|
159
|
+
onInsertRow={onInsertRow && pkCol ? handleRowAppended : undefined}
|
|
160
|
+
columns={columnNames} selectedRows={selectedRowIndices.map((i) => displayRows[i]!).filter(Boolean)} connectionName={connectionName}
|
|
161
|
+
searchTerm={searchTerm} onSearchChange={setSearchTerm} onColumnJump={handleColumnJump}
|
|
162
|
+
colSearchOpen={colSearchOpen} onColSearchChange={setColSearchOpen} />
|
|
163
|
+
|
|
164
|
+
{loading && <div className="absolute inset-0 z-30 flex items-center justify-center bg-background/60"><Loader2 className="size-5 animate-spin text-primary" /></div>}
|
|
165
|
+
|
|
166
|
+
<div className="flex-1 min-h-0">
|
|
167
|
+
<DataEditor ref={gridRef} columns={columns} rows={displayRows.length}
|
|
168
|
+
getCellContent={getCellContent} getCellsForSelection={true}
|
|
169
|
+
onCellEdited={onCellEdited} onPaste={handlePaste}
|
|
170
|
+
theme={theme} freezeColumns={freezeColumns} freezeTrailingRows={frozenTrailingRows}
|
|
171
|
+
rowMarkers={pkCol ? "checkbox-visible" : "number"}
|
|
172
|
+
gridSelection={gridSelection} onGridSelectionChange={onGridSelectionChange}
|
|
173
|
+
onColumnResize={handleColumnResize as any}
|
|
174
|
+
onHeaderMenuClick={handleHeaderMenuClick as any} onCellContextMenu={handleCellContextMenu as any}
|
|
175
|
+
getRowThemeOverride={getRowThemeOverride as any}
|
|
176
|
+
trailingRowOptions={onInsertRow && pkCol ? { sticky: true, tint: true } : undefined}
|
|
177
|
+
onRowAppended={onInsertRow ? handleRowAppended : undefined}
|
|
178
|
+
headerIcons={HEADER_ICONS} smoothScrollX smoothScrollY width="100%" height="100%" />
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{hasPending && <GlideSaveBar pendingCount={pendingCount} onSave={commitAll} onDiscard={() => { discardAll(); setInsertedRows([]); }} />}
|
|
182
|
+
{previewData && <GlideDataPreviewPanel data={previewData} onClose={() => setPreviewData(null)} onOpenInTab={openPreviewInTab} />}
|
|
183
|
+
<GlideGridPagination total={total} page={page} totalPages={Math.ceil(total / limit) || 1} onPageChange={onPageChange} />
|
|
184
|
+
|
|
185
|
+
{headerMenu && (
|
|
186
|
+
<GlideHeaderMenu colName={headerMenu.colName} bounds={headerMenu.bounds}
|
|
187
|
+
isPinned={pinnedCols.has(headerMenu.colName)} filterValue={columnFilters[headerMenu.colName] ?? ""}
|
|
188
|
+
sortState={orderBy === headerMenu.colName ? (orderDir === "ASC" ? "asc" : "desc") : null}
|
|
189
|
+
onFilter={(val) => { if (!onColumnFilter) return; const n = { ...columnFilters }; if (val) n[headerMenu.colName] = val; else delete n[headerMenu.colName]; onColumnFilter(n); }}
|
|
190
|
+
onSort={() => { if (onToggleSort) onToggleSort(headerMenu.colName); }}
|
|
191
|
+
onClearSort={onClearSort}
|
|
192
|
+
onTogglePin={() => togglePinColumn(headerMenu.colName)} onClose={() => setHeaderMenu(null)} />
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{contextMenu && contextRow && (
|
|
196
|
+
<GlideContextMenu position={contextMenu.position} isPinned={pinnedPks.has(contextPk)}
|
|
197
|
+
onViewRow={() => openRowPreview(contextMenu.rowIdx)}
|
|
198
|
+
onViewCell={contextCellViewable ? () => openCellPreview(contextMenu.rowIdx, contextMenu.colIdx) : undefined}
|
|
199
|
+
onPinRow={() => togglePinRow(contextMenu.rowIdx)}
|
|
200
|
+
onDeleteRow={() => { if (pkCol && onRowDelete && contextRow) onRowDelete(pkCol, contextRow[pkCol]); }}
|
|
201
|
+
onOpenFkTable={contextFk && contextCellValue != null && connectionId ? () => openFkTable(contextFk, contextCellValue) : undefined}
|
|
202
|
+
fkLabel={contextFk ? `Open ${contextFk.table}.${contextFk.column}` : undefined}
|
|
203
|
+
onClose={() => setContextMenu(null)} />
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { Eye, Sparkles, WrapText, ExternalLink, X, GripHorizontal } from "lucide-react";
|
|
3
|
+
import Editor from "@monaco-editor/react";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { useMonacoTheme } from "@/lib/use-monaco-theme";
|
|
6
|
+
|
|
7
|
+
export interface PreviewData {
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
language: string;
|
|
11
|
+
viewerKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PreviewPanelProps {
|
|
15
|
+
data: PreviewData;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onOpenInTab: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Inline preview panel for cell/row content with Monaco editor */
|
|
21
|
+
export function GlideDataPreviewPanel({ data, onClose, onOpenInTab }: PreviewPanelProps) {
|
|
22
|
+
const monacoTheme = useMonacoTheme();
|
|
23
|
+
const [wordWrap, setWordWrap] = useState(true);
|
|
24
|
+
const [displayContent, setDisplayContent] = useState(data.content);
|
|
25
|
+
const [beautified, setBeautified] = useState(false);
|
|
26
|
+
const canBeautify = data.language === "json" || data.language === "xml";
|
|
27
|
+
|
|
28
|
+
// Reset state when data changes
|
|
29
|
+
const prevKey = useRef(data.title);
|
|
30
|
+
if (prevKey.current !== data.title) {
|
|
31
|
+
prevKey.current = data.title;
|
|
32
|
+
setDisplayContent(data.content);
|
|
33
|
+
setBeautified(false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toggleBeautify = useCallback(() => {
|
|
37
|
+
if (beautified) {
|
|
38
|
+
setDisplayContent(data.content);
|
|
39
|
+
setBeautified(false);
|
|
40
|
+
} else if (data.language === "json") {
|
|
41
|
+
try { setDisplayContent(JSON.stringify(JSON.parse(data.content.trim()), null, 2)); setBeautified(true); } catch { /* invalid */ }
|
|
42
|
+
} else if (data.language === "xml") {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
const formatted = data.content.trim().replace(/>\s*</g, ">\n<").split("\n").map((line) => {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (trimmed.startsWith("</")) depth = Math.max(0, depth - 1);
|
|
47
|
+
const indented = " ".repeat(depth) + trimmed;
|
|
48
|
+
if (trimmed.startsWith("<") && !trimmed.startsWith("</") && !trimmed.endsWith("/>") && !trimmed.startsWith("<?")) depth++;
|
|
49
|
+
return indented;
|
|
50
|
+
}).join("\n");
|
|
51
|
+
setDisplayContent(formatted);
|
|
52
|
+
setBeautified(true);
|
|
53
|
+
}
|
|
54
|
+
}, [beautified, data.content, data.language]);
|
|
55
|
+
|
|
56
|
+
const [panelHeight, setPanelHeight] = useState(200);
|
|
57
|
+
const handleDrag = useCallback((e: React.MouseEvent) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
const startY = e.clientY;
|
|
60
|
+
const startH = panelHeight;
|
|
61
|
+
const onMove = (ev: MouseEvent) => setPanelHeight(Math.max(80, startH + (startY - ev.clientY)));
|
|
62
|
+
const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
63
|
+
document.addEventListener("mousemove", onMove);
|
|
64
|
+
document.addEventListener("mouseup", onUp);
|
|
65
|
+
}, [panelHeight]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="shrink-0 border-t border-border flex flex-col" style={{ height: panelHeight }}>
|
|
69
|
+
<div onMouseDown={handleDrag}
|
|
70
|
+
className="shrink-0 h-1.5 cursor-row-resize bg-border/50 hover:bg-primary/30 flex items-center justify-center transition-colors">
|
|
71
|
+
<GripHorizontal className="size-3 text-muted-foreground/50" />
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex items-center gap-1 px-2 py-1 bg-muted/50 border-b border-border shrink-0">
|
|
74
|
+
<Eye className="size-3 text-muted-foreground" />
|
|
75
|
+
<span className="text-xs font-medium text-foreground truncate flex-1">{data.title}</span>
|
|
76
|
+
{canBeautify && (
|
|
77
|
+
<button type="button" onClick={toggleBeautify} title={beautified ? "Raw" : "Beautify"}
|
|
78
|
+
className={`p-0.5 rounded transition-colors ${beautified ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}>
|
|
79
|
+
<Sparkles className="size-3" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
<button type="button" onClick={() => setWordWrap(!wordWrap)} title={wordWrap ? "No wrap" : "Word wrap"}
|
|
83
|
+
className={`p-0.5 rounded transition-colors ${wordWrap ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}>
|
|
84
|
+
<WrapText className="size-3" />
|
|
85
|
+
</button>
|
|
86
|
+
<button type="button" onClick={onOpenInTab} title="Open in new tab"
|
|
87
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
88
|
+
<ExternalLink className="size-3" />
|
|
89
|
+
<span className="hidden sm:inline">Open in Tab</span>
|
|
90
|
+
</button>
|
|
91
|
+
<button type="button" onClick={onClose} title="Close preview (Esc)"
|
|
92
|
+
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors">
|
|
93
|
+
<X className="size-3" />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex-1 min-h-0">
|
|
97
|
+
<Editor
|
|
98
|
+
height="100%"
|
|
99
|
+
language={data.language === "plaintext" ? undefined : data.language}
|
|
100
|
+
value={displayContent}
|
|
101
|
+
theme={monacoTheme}
|
|
102
|
+
options={{
|
|
103
|
+
readOnly: true, minimap: { enabled: false }, scrollBeyondLastLine: false,
|
|
104
|
+
wordWrap: wordWrap ? "on" : "off", lineNumbers: "on", fontSize: 12,
|
|
105
|
+
folding: true, bracketPairColorization: { enabled: true },
|
|
106
|
+
domReadOnly: true, contextmenu: false, overviewRulerLanes: 0,
|
|
107
|
+
}}
|
|
108
|
+
loading={<Loader2 className="size-4 animate-spin text-muted-foreground" />}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface PaginationProps {
|
|
4
|
+
total: number;
|
|
5
|
+
page: number;
|
|
6
|
+
totalPages: number;
|
|
7
|
+
onPageChange: (page: number) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pagination footer for Glide Data Grid — row count + page nav */
|
|
11
|
+
export function GlideGridPagination({ total, page, totalPages, onPageChange }: PaginationProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-background shrink-0 text-xs text-muted-foreground">
|
|
14
|
+
<span>{total.toLocaleString()} rows</span>
|
|
15
|
+
{/* Shortcut hints — desktop only */}
|
|
16
|
+
<div className="hidden md:flex items-center gap-2 text-[10px] text-muted-foreground/50">
|
|
17
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">/</kbd> columns</span>
|
|
18
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">{"\u2318"}A</kbd> select all</span>
|
|
19
|
+
<span><kbd className="px-1 py-0.5 rounded bg-muted text-[9px]">{"\u2318"}C</kbd> copy</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex items-center gap-2">
|
|
22
|
+
<button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}
|
|
23
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
24
|
+
<ChevronLeft className="size-3.5" />
|
|
25
|
+
</button>
|
|
26
|
+
<span>{page} / {totalPages}</span>
|
|
27
|
+
<button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}
|
|
28
|
+
className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
29
|
+
<ChevronRight className="size-3.5" />
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { Search, X, Trash2, Plus, Columns } from "lucide-react";
|
|
3
|
+
import { ExportButton } from "./export-button";
|
|
4
|
+
import { GlideColumnSearch } from "./glide-column-search";
|
|
5
|
+
|
|
6
|
+
interface ToolbarProps {
|
|
7
|
+
hasSelection: boolean;
|
|
8
|
+
selectedCount: number;
|
|
9
|
+
onBulkDelete?: () => void;
|
|
10
|
+
onInsertRow?: () => void;
|
|
11
|
+
columns: string[];
|
|
12
|
+
selectedRows: Record<string, unknown>[];
|
|
13
|
+
connectionName?: string;
|
|
14
|
+
searchTerm: string;
|
|
15
|
+
onSearchChange: (term: string) => void;
|
|
16
|
+
onColumnJump?: (colName: string) => void;
|
|
17
|
+
/** Controlled column search open state (from parent "/" shortcut) */
|
|
18
|
+
colSearchOpen?: boolean;
|
|
19
|
+
onColSearchChange?: (open: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Toolbar above the Glide Data Grid — bulk actions, search, column jump, insert, export.
|
|
24
|
+
*/
|
|
25
|
+
export function GlideGridToolbar({
|
|
26
|
+
hasSelection, selectedCount, onBulkDelete, onInsertRow,
|
|
27
|
+
columns, selectedRows, connectionName,
|
|
28
|
+
searchTerm, onSearchChange, onColumnJump,
|
|
29
|
+
colSearchOpen: externalOpen, onColSearchChange,
|
|
30
|
+
}: ToolbarProps) {
|
|
31
|
+
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
|
32
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
33
|
+
const colSearchOpen = externalOpen ?? internalOpen;
|
|
34
|
+
const setColSearchOpen = onColSearchChange ?? setInternalOpen;
|
|
35
|
+
const colSearchBtnRef = useRef<HTMLButtonElement>(null);
|
|
36
|
+
|
|
37
|
+
const colSearchPos = colSearchBtnRef.current
|
|
38
|
+
? (() => { const r = colSearchBtnRef.current!.getBoundingClientRect(); return { x: r.left, y: r.bottom + 4 }; })()
|
|
39
|
+
: { x: 0, y: 0 };
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center gap-2 px-2 py-1 border-b border-border bg-background shrink-0">
|
|
43
|
+
{/* Selection info + bulk actions */}
|
|
44
|
+
{hasSelection && (
|
|
45
|
+
<div className="flex items-center gap-1.5 text-xs">
|
|
46
|
+
<span className="text-muted-foreground">{selectedCount} selected</span>
|
|
47
|
+
{onBulkDelete && (
|
|
48
|
+
confirmBulkDelete ? (
|
|
49
|
+
<span className="flex items-center gap-1">
|
|
50
|
+
<button type="button" onClick={() => { onBulkDelete(); setConfirmBulkDelete(false); }}
|
|
51
|
+
className="text-destructive text-[10px] font-medium hover:underline">
|
|
52
|
+
Delete {selectedCount}?
|
|
53
|
+
</button>
|
|
54
|
+
<button type="button" onClick={() => setConfirmBulkDelete(false)}
|
|
55
|
+
className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
|
|
56
|
+
</span>
|
|
57
|
+
) : (
|
|
58
|
+
<button type="button" onClick={() => setConfirmBulkDelete(true)}
|
|
59
|
+
className="p-0.5 text-muted-foreground hover:text-destructive">
|
|
60
|
+
<Trash2 className="size-3" />
|
|
61
|
+
</button>
|
|
62
|
+
)
|
|
63
|
+
)}
|
|
64
|
+
<ExportButton columns={columns} rows={selectedRows} filename={`${connectionName ?? "db"}-selected`} />
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div className="flex-1" />
|
|
69
|
+
|
|
70
|
+
{/* Client-side search */}
|
|
71
|
+
<div className="flex items-center gap-1 text-xs">
|
|
72
|
+
<Search className="size-3 text-muted-foreground" />
|
|
73
|
+
<input value={searchTerm} onChange={(e) => onSearchChange(e.target.value)}
|
|
74
|
+
placeholder="Search page…"
|
|
75
|
+
className="w-24 bg-transparent outline-none text-foreground placeholder:text-muted-foreground text-xs" />
|
|
76
|
+
{searchTerm && (
|
|
77
|
+
<button type="button" onClick={() => onSearchChange("")}
|
|
78
|
+
className="text-muted-foreground hover:text-foreground">
|
|
79
|
+
<X className="size-3" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Column jump */}
|
|
85
|
+
{onColumnJump && (
|
|
86
|
+
<button ref={colSearchBtnRef} type="button" onClick={() => setColSearchOpen(!colSearchOpen)}
|
|
87
|
+
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors" title="Jump to column (/)">
|
|
88
|
+
<Columns className="size-3.5" />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Insert row */}
|
|
93
|
+
{onInsertRow && (
|
|
94
|
+
<button type="button" onClick={onInsertRow}
|
|
95
|
+
className="p-0.5 rounded text-muted-foreground hover:text-primary transition-colors" title="Insert row">
|
|
96
|
+
<Plus className="size-3.5" />
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{colSearchOpen && onColumnJump && (
|
|
101
|
+
<GlideColumnSearch columns={columns} onSelect={onColumnJump} onClose={() => setColSearchOpen(false)} anchorRect={colSearchPos} />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -7,6 +7,7 @@ export interface GridColumnSchema {
|
|
|
7
7
|
nullable: boolean;
|
|
8
8
|
pk: boolean;
|
|
9
9
|
defaultValue?: string | null;
|
|
10
|
+
fk?: { table: string; column: string } | null;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/** Unified props interface for the Glide Data Grid wrapper component */
|
|
@@ -35,6 +36,7 @@ export interface GlideGridProps {
|
|
|
35
36
|
orderBy?: string | null;
|
|
36
37
|
orderDir?: "ASC" | "DESC";
|
|
37
38
|
onToggleSort?: (column: string) => void;
|
|
39
|
+
onClearSort?: () => void;
|
|
38
40
|
/** Per-column ILIKE filters (server-side) */
|
|
39
41
|
columnFilters?: Record<string, string>;
|
|
40
42
|
onColumnFilter?: (filters: Record<string, string>) => void;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Filter, Pin, PinOff, ArrowUp, ArrowDown, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface HeaderMenuProps {
|
|
6
|
+
colName: string;
|
|
7
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
8
|
+
isPinned: boolean;
|
|
9
|
+
filterValue: string;
|
|
10
|
+
sortState: "asc" | "desc" | null;
|
|
11
|
+
onFilter: (value: string) => void;
|
|
12
|
+
onSort: () => void;
|
|
13
|
+
onClearSort?: () => void;
|
|
14
|
+
onTogglePin: () => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Header column dropdown menu — filter input, sort toggle, pin/unpin.
|
|
20
|
+
* Rendered via React portal into #portal div (required by Glide Data Grid).
|
|
21
|
+
*/
|
|
22
|
+
export function GlideHeaderMenu({
|
|
23
|
+
colName, bounds, isPinned, filterValue, sortState,
|
|
24
|
+
onFilter, onSort, onClearSort, onTogglePin, onClose,
|
|
25
|
+
}: HeaderMenuProps) {
|
|
26
|
+
const [localFilter, setLocalFilter] = useState(filterValue);
|
|
27
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
28
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
29
|
+
|
|
30
|
+
// Close on outside click
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handler = (e: MouseEvent) => {
|
|
33
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener("mousedown", handler);
|
|
36
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
37
|
+
}, [onClose]);
|
|
38
|
+
|
|
39
|
+
// Close on Escape
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
42
|
+
document.addEventListener("keydown", handler);
|
|
43
|
+
return () => document.removeEventListener("keydown", handler);
|
|
44
|
+
}, [onClose]);
|
|
45
|
+
|
|
46
|
+
// Debounced filter
|
|
47
|
+
const handleFilterChange = (val: string) => {
|
|
48
|
+
setLocalFilter(val);
|
|
49
|
+
clearTimeout(debounceRef.current);
|
|
50
|
+
debounceRef.current = setTimeout(() => onFilter(val), 300);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Clamp position to viewport
|
|
54
|
+
const menuWidth = 220;
|
|
55
|
+
const left = Math.min(bounds.x, window.innerWidth - menuWidth - 8);
|
|
56
|
+
const top = bounds.y + bounds.height + 2;
|
|
57
|
+
|
|
58
|
+
const portal = document.getElementById("portal");
|
|
59
|
+
if (!portal) return null;
|
|
60
|
+
|
|
61
|
+
return createPortal(
|
|
62
|
+
<div ref={ref} style={{ position: "fixed", left, top, zIndex: 10000 }}
|
|
63
|
+
className="w-[220px] bg-popover border border-border rounded-md shadow-lg text-xs overflow-hidden">
|
|
64
|
+
{/* Column name header */}
|
|
65
|
+
<div className="px-3 py-1.5 border-b border-border text-muted-foreground font-medium truncate">
|
|
66
|
+
{colName}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Filter input */}
|
|
70
|
+
<div className="px-2 py-1.5 border-b border-border">
|
|
71
|
+
<div className="flex items-center gap-1">
|
|
72
|
+
<Filter className="size-3 text-muted-foreground shrink-0" />
|
|
73
|
+
<input
|
|
74
|
+
autoFocus type="text" value={localFilter}
|
|
75
|
+
onChange={(e) => handleFilterChange(e.target.value)}
|
|
76
|
+
placeholder="Filter (ILIKE)…"
|
|
77
|
+
className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground text-xs"
|
|
78
|
+
/>
|
|
79
|
+
{localFilter && (
|
|
80
|
+
<button type="button" onClick={() => handleFilterChange("")}
|
|
81
|
+
className="text-muted-foreground hover:text-foreground">
|
|
82
|
+
<X className="size-3" />
|
|
83
|
+
</button>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Actions */}
|
|
89
|
+
<div className="py-1">
|
|
90
|
+
<button type="button" onClick={() => { onSort(); onClose(); }}
|
|
91
|
+
className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
|
|
92
|
+
{sortState === "asc" ? <ArrowDown className="size-3" /> : <ArrowUp className="size-3" />}
|
|
93
|
+
{sortState === "asc" ? "Sort Descending" : "Sort Ascending"}
|
|
94
|
+
</button>
|
|
95
|
+
{sortState && onClearSort && (
|
|
96
|
+
<button type="button" onClick={() => { onClearSort(); onClose(); }}
|
|
97
|
+
className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
|
|
98
|
+
<X className="size-3" />
|
|
99
|
+
Clear Sort
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
<button type="button" onClick={() => { onTogglePin(); onClose(); }}
|
|
103
|
+
className="w-full text-left px-3 py-1.5 hover:bg-muted flex items-center gap-2 text-foreground">
|
|
104
|
+
{isPinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
|
|
105
|
+
{isPinned ? "Unpin Column" : "Pin Column"}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>,
|
|
109
|
+
portal,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Save, Undo2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface SaveBarProps {
|
|
4
|
+
pendingCount: number;
|
|
5
|
+
onSave: () => void;
|
|
6
|
+
onDiscard: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Floating save bar shown when there are pending cell edits.
|
|
11
|
+
* Save with click or Mod+Enter. Discard with Escape.
|
|
12
|
+
*/
|
|
13
|
+
export function GlideSaveBar({ pendingCount, onSave, onDiscard }: SaveBarProps) {
|
|
14
|
+
if (pendingCount === 0) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex items-center gap-2 px-3 py-1.5 border-t border-amber-400/50 bg-amber-50 dark:bg-amber-950/30 shrink-0 text-xs">
|
|
18
|
+
<span className="text-amber-700 dark:text-amber-300 font-medium">
|
|
19
|
+
{pendingCount} pending edit{pendingCount > 1 ? "s" : ""}
|
|
20
|
+
</span>
|
|
21
|
+
<div className="flex-1" />
|
|
22
|
+
<button type="button" onClick={onDiscard}
|
|
23
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
24
|
+
<Undo2 className="size-3" /> Discard
|
|
25
|
+
</button>
|
|
26
|
+
<button type="button" onClick={onSave}
|
|
27
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
|
|
28
|
+
<Save className="size-3" /> Save
|
|
29
|
+
<kbd className="ml-1 text-[9px] opacity-70">⌘↵</kbd>
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|