@gblikas/querykit 0.0.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/.cursor/BUGBOT.md +21 -0
- package/.cursor/rules/01-project-structure.mdc +77 -0
- package/.cursor/rules/02-typescript-standards.mdc +105 -0
- package/.cursor/rules/03-testing-standards.mdc +78 -0
- package/.cursor/rules/04-query-language.mdc +79 -0
- package/.cursor/rules/05-solid-principles.mdc +118 -0
- package/.cursor/rules/liqe-readme-docs.mdc +438 -0
- package/.devcontainer/devcontainer.json +25 -0
- package/.eslintignore +1 -0
- package/.eslintrc.js +39 -0
- package/.github/dependabot.yml +12 -0
- package/.github/workflows/ci.yml +114 -0
- package/.github/workflows/publish.yml +61 -0
- package/.husky/pre-commit +30 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +187 -0
- package/LICENSE +674 -0
- package/README.md +237 -0
- package/dist/adapters/drizzle/index.d.ts +122 -0
- package/dist/adapters/drizzle/index.js +166 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/index.js +25 -0
- package/dist/adapters/types.d.ts +60 -0
- package/dist/adapters/types.js +8 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +118 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.js +18 -0
- package/dist/parser/parser.d.ts +51 -0
- package/dist/parser/parser.js +201 -0
- package/dist/parser/types.d.ts +68 -0
- package/dist/parser/types.js +5 -0
- package/dist/query/builder.d.ts +61 -0
- package/dist/query/builder.js +188 -0
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +18 -0
- package/dist/query/types.d.ts +79 -0
- package/dist/query/types.js +2 -0
- package/dist/security/index.d.ts +2 -0
- package/dist/security/index.js +18 -0
- package/dist/security/types.d.ts +181 -0
- package/dist/security/types.js +43 -0
- package/dist/security/validator.d.ts +191 -0
- package/dist/security/validator.js +344 -0
- package/dist/translators/drizzle/index.d.ts +73 -0
- package/dist/translators/drizzle/index.js +260 -0
- package/dist/translators/index.d.ts +8 -0
- package/dist/translators/index.js +27 -0
- package/dist/translators/sql/index.d.ts +108 -0
- package/dist/translators/sql/index.js +252 -0
- package/dist/translators/types.d.ts +39 -0
- package/dist/translators/types.js +8 -0
- package/examples/qk-next/README.md +35 -0
- package/examples/qk-next/app/favicon.ico +0 -0
- package/examples/qk-next/app/globals.css +122 -0
- package/examples/qk-next/app/layout.tsx +121 -0
- package/examples/qk-next/app/page.tsx +813 -0
- package/examples/qk-next/app/providers.tsx +80 -0
- package/examples/qk-next/components/aurora-background.tsx +12 -0
- package/examples/qk-next/components/github-stars.tsx +51 -0
- package/examples/qk-next/components/mode-toggle.tsx +27 -0
- package/examples/qk-next/components/reactbits/blocks/Backgrounds/Aurora/Aurora.tsx +217 -0
- package/examples/qk-next/components/reactbits/blocks/Backgrounds/LightRays/LightRays.tsx +474 -0
- package/examples/qk-next/components/theme-provider.tsx +11 -0
- package/examples/qk-next/components/ui/card.tsx +92 -0
- package/examples/qk-next/components/ui/command.tsx +184 -0
- package/examples/qk-next/components/ui/dialog.tsx +143 -0
- package/examples/qk-next/components/ui/drawer.tsx +135 -0
- package/examples/qk-next/components/ui/hover-card.tsx +44 -0
- package/examples/qk-next/components/ui/icons.tsx +148 -0
- package/examples/qk-next/components/ui/sonner.tsx +26 -0
- package/examples/qk-next/components/ui/table.tsx +117 -0
- package/examples/qk-next/components.json +21 -0
- package/examples/qk-next/eslint.config.mjs +21 -0
- package/examples/qk-next/jsrepo.json +13 -0
- package/examples/qk-next/lib/utils.ts +6 -0
- package/examples/qk-next/next.config.ts +8 -0
- package/examples/qk-next/package.json +48 -0
- package/examples/qk-next/pnpm-lock.yaml +5558 -0
- package/examples/qk-next/postcss.config.mjs +5 -0
- package/examples/qk-next/public/file.svg +1 -0
- package/examples/qk-next/public/globe.svg +1 -0
- package/examples/qk-next/public/next.svg +1 -0
- package/examples/qk-next/public/vercel.svg +1 -0
- package/examples/qk-next/public/window.svg +1 -0
- package/examples/qk-next/tsconfig.json +42 -0
- package/examples/qk-next/types/sonner.d.ts +3 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/src/adapters/drizzle/drizzle-adapter.test.ts +115 -0
- package/src/adapters/drizzle/index.ts +299 -0
- package/src/adapters/index.ts +11 -0
- package/src/adapters/types.ts +72 -0
- package/src/index.ts +194 -0
- package/src/integration.test.ts +202 -0
- package/src/parser/index.ts +2 -0
- package/src/parser/parser.test.ts +1056 -0
- package/src/parser/parser.ts +268 -0
- package/src/parser/types.ts +97 -0
- package/src/query/builder.test.ts +272 -0
- package/src/query/builder.ts +274 -0
- package/src/query/index.ts +2 -0
- package/src/query/types.ts +107 -0
- package/src/security/index.ts +2 -0
- package/src/security/types.ts +210 -0
- package/src/security/validator.test.ts +459 -0
- package/src/security/validator.ts +395 -0
- package/src/security.test.ts +366 -0
- package/src/translators/drizzle/drizzle-translator.test.ts +128 -0
- package/src/translators/drizzle/index.test.ts +45 -0
- package/src/translators/drizzle/index.ts +346 -0
- package/src/translators/index.ts +14 -0
- package/src/translators/sql/index.test.ts +45 -0
- package/src/translators/sql/index.ts +331 -0
- package/src/translators/sql/sql-translator.test.ts +419 -0
- package/src/translators/types.ts +44 -0
- package/src/types/sonner.d.ts +3 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState, useCallback, useRef, JSX } from 'react';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/pglite';
|
|
5
|
+
import { usePGlite } from '@electric-sql/pglite-react';
|
|
6
|
+
import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
|
|
7
|
+
import { InferSelectModel, sql, SQLWrapper } from 'drizzle-orm';
|
|
8
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
9
|
+
import {
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableRow
|
|
16
|
+
} from '@/components/ui/table';
|
|
17
|
+
import {
|
|
18
|
+
QueryParser,
|
|
19
|
+
SqlTranslator,
|
|
20
|
+
DrizzleAdapter,
|
|
21
|
+
createQueryKit,
|
|
22
|
+
IDrizzleAdapterOptions
|
|
23
|
+
} from '@gblikas/querykit';
|
|
24
|
+
import { Copy, Check, Search, SearchCode, ChevronUp } from 'lucide-react';
|
|
25
|
+
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
26
|
+
import { atomOneDarkReasonable } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
|
27
|
+
import sqlLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
|
|
28
|
+
import jsonLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/json';
|
|
29
|
+
import { toast } from 'sonner';
|
|
30
|
+
import Aurora from '@/components/reactbits/blocks/Backgrounds/Aurora/Aurora';
|
|
31
|
+
import { PGlite } from '@electric-sql/pglite';
|
|
32
|
+
|
|
33
|
+
// Simple GitHub-like highlighter for key:value tokens that colors only the value
|
|
34
|
+
const escapeHtml = (input: string): string =>
|
|
35
|
+
input
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, ''');
|
|
41
|
+
|
|
42
|
+
const highlightQueryHtml = (input: string): string => {
|
|
43
|
+
if (!input) return '';
|
|
44
|
+
const parts = input.split(/(\s+)/); // keep spaces
|
|
45
|
+
return parts
|
|
46
|
+
.map(part => {
|
|
47
|
+
if (/^\s+$/.test(part)) return part; // preserve spaces, rely on whitespace-pre-wrap
|
|
48
|
+
const idx = part.indexOf(':');
|
|
49
|
+
if (idx > 0) {
|
|
50
|
+
const key = escapeHtml(part.slice(0, idx));
|
|
51
|
+
const value = escapeHtml(part.slice(idx + 1));
|
|
52
|
+
// Note: no horizontal padding to keep overlay width identical to input text for caret alignment
|
|
53
|
+
return `${key}:<span class="text-blue-400 bg-blue-500/20 rounded">${value}</span>`;
|
|
54
|
+
}
|
|
55
|
+
return escapeHtml(part);
|
|
56
|
+
})
|
|
57
|
+
.join('');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const tasks = pgTable('tasks', {
|
|
61
|
+
id: serial('id').primaryKey(),
|
|
62
|
+
title: text('title').notNull(),
|
|
63
|
+
status: text('status').notNull(),
|
|
64
|
+
priority: integer('priority').default(0).notNull(),
|
|
65
|
+
completed: boolean('completed').default(false).notNull()
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
type Task = InferSelectModel<typeof tasks>;
|
|
69
|
+
|
|
70
|
+
export default function Home(): JSX.Element {
|
|
71
|
+
const [query, setQuery] = useState('');
|
|
72
|
+
const [results, setResults] = useState<Task[]>([]);
|
|
73
|
+
const [dbReady, setDbReady] = useState(false);
|
|
74
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
75
|
+
const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false);
|
|
76
|
+
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
77
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
78
|
+
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
|
79
|
+
useState<number>(-1);
|
|
80
|
+
const [lastExecutedQuery, setLastExecutedQuery] = useState('');
|
|
81
|
+
const [generatedSQL, setGeneratedSQL] = useState('SELECT * FROM tasks');
|
|
82
|
+
const [, setLastExecutionMs] = useState<number | null>(null);
|
|
83
|
+
const [rowsScanned, setRowsScanned] = useState<number | null>(null);
|
|
84
|
+
const [operatorsUsed, setOperatorsUsed] = useState<string[]>([]);
|
|
85
|
+
const [usedQueryKit, setUsedQueryKit] = useState<boolean>(false);
|
|
86
|
+
const [explainJson, setExplainJson] = useState<string | null>(null);
|
|
87
|
+
const [, setPlanningTimeMs] = useState<number | null>(null);
|
|
88
|
+
const [, setExecutionTimeMs] = useState<number | null>(null);
|
|
89
|
+
const [explainError, setExplainError] = useState<string | null>(null);
|
|
90
|
+
const [dbExecutionMs, setDbExecutionMs] = useState<number | null>(null);
|
|
91
|
+
const [, setParseTranslateMs] = useState<number | null>(null);
|
|
92
|
+
const [, setExplainLatencyMs] = useState<number | null>(null);
|
|
93
|
+
const [, setBaselineFetchMs] = useState<number | null>(null);
|
|
94
|
+
const [hasCopiedSql, setHasCopiedSql] = useState(false);
|
|
95
|
+
const [showExplain, setShowExplain] = useState(false);
|
|
96
|
+
const [isResultsOpen, setIsResultsOpen] = useState(false);
|
|
97
|
+
const sqlCardAnchorRef = useRef<HTMLDivElement | null>(null);
|
|
98
|
+
const [drawerTopPx, setDrawerTopPx] = useState<number>(0);
|
|
99
|
+
const COPY_FEEDBACK_MS = 2000;
|
|
100
|
+
|
|
101
|
+
// Register languages once
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
try {
|
|
104
|
+
(
|
|
105
|
+
SyntaxHighlighter as unknown as typeof SyntaxHighlighter
|
|
106
|
+
).registerLanguage('sql', sqlLanguage);
|
|
107
|
+
(
|
|
108
|
+
SyntaxHighlighter as unknown as typeof SyntaxHighlighter
|
|
109
|
+
).registerLanguage('json', jsonLanguage);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Failed to register languages:', error);
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Pretty-print SQL by placing major clauses and boolean operators on new lines
|
|
116
|
+
const formatSqlForDisplay = useCallback((sqlText: string): string => {
|
|
117
|
+
if (!sqlText) return '';
|
|
118
|
+
let compact = sqlText.replace(/\s+/g, ' ').trim();
|
|
119
|
+
const clauseKeywords = [
|
|
120
|
+
'FROM',
|
|
121
|
+
'WHERE',
|
|
122
|
+
'GROUP BY',
|
|
123
|
+
'HAVING',
|
|
124
|
+
'ORDER BY',
|
|
125
|
+
'LIMIT',
|
|
126
|
+
'OFFSET'
|
|
127
|
+
];
|
|
128
|
+
for (const kw of clauseKeywords) {
|
|
129
|
+
const re = new RegExp(`\\s+${kw}\\b`, 'gi');
|
|
130
|
+
compact = compact.replace(re, `\n${kw}`);
|
|
131
|
+
}
|
|
132
|
+
compact = compact.replace(
|
|
133
|
+
/\s+(LEFT|RIGHT|FULL|INNER|CROSS)?\s*JOIN\b/gi,
|
|
134
|
+
match => `\n${match.trim().toUpperCase()}`
|
|
135
|
+
);
|
|
136
|
+
compact = compact.replace(/\s+ON\b/gi, '\nON');
|
|
137
|
+
compact = compact.replace(/\s+(AND|OR)\s+/gi, '\n $1 ');
|
|
138
|
+
return compact;
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const formattedSQL = useMemo(
|
|
142
|
+
() => formatSqlForDisplay(generatedSQL),
|
|
143
|
+
[generatedSQL, formatSqlForDisplay]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Static suggestions for keyboard navigation
|
|
147
|
+
const suggestions = useMemo(
|
|
148
|
+
() => [
|
|
149
|
+
{ q: 'status:done', desc: 'Find completed tasks' },
|
|
150
|
+
{ q: 'priority:>=2', desc: 'High priority tasks' },
|
|
151
|
+
{ q: 'status:doing AND priority:<3', desc: 'In-progress, low priority' },
|
|
152
|
+
{ q: 'title:docs OR title:ship', desc: 'Documentation or shipping' },
|
|
153
|
+
{ q: 'NOT completed:true', desc: 'Incomplete tasks' }
|
|
154
|
+
],
|
|
155
|
+
[]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Use the PGlite instance from context
|
|
159
|
+
const pglite = usePGlite();
|
|
160
|
+
const db = useMemo(() => drizzle(pglite as unknown as PGlite), [pglite]);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const seed = async (): Promise<void> => {
|
|
164
|
+
try {
|
|
165
|
+
console.log('Seeding database...');
|
|
166
|
+
await db.execute(sql`
|
|
167
|
+
create table if not exists tasks (
|
|
168
|
+
id serial primary key,
|
|
169
|
+
title text not null,
|
|
170
|
+
status text not null,
|
|
171
|
+
priority integer not null default 0,
|
|
172
|
+
completed boolean not null default false
|
|
173
|
+
);
|
|
174
|
+
`);
|
|
175
|
+
const existing = await db.select().from(tasks).limit(1);
|
|
176
|
+
if (existing.length === 0) {
|
|
177
|
+
await db.insert(tasks).values([
|
|
178
|
+
{
|
|
179
|
+
title: 'Write docs',
|
|
180
|
+
status: 'todo',
|
|
181
|
+
priority: 2,
|
|
182
|
+
completed: false
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
title: 'Ship alpha',
|
|
186
|
+
status: 'doing',
|
|
187
|
+
priority: 1,
|
|
188
|
+
completed: false
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
title: 'Fix bugs',
|
|
192
|
+
status: 'doing',
|
|
193
|
+
priority: 3,
|
|
194
|
+
completed: false
|
|
195
|
+
},
|
|
196
|
+
{ title: 'Publish', status: 'done', priority: 1, completed: true }
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
console.log('Database seeded successfully');
|
|
200
|
+
|
|
201
|
+
// Load initial data and show default query details
|
|
202
|
+
const data = await db.select().from(tasks);
|
|
203
|
+
setResults(data as Task[]);
|
|
204
|
+
setLastExecutedQuery('(default)');
|
|
205
|
+
setGeneratedSQL('SELECT * FROM tasks');
|
|
206
|
+
setDbReady(true);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error('Database seeding failed:', error);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
void seed();
|
|
212
|
+
}, [db]);
|
|
213
|
+
|
|
214
|
+
const parser = useMemo(() => new QueryParser(), []);
|
|
215
|
+
const sqlTranslator = useMemo(
|
|
216
|
+
() => new SqlTranslator({ useParameters: false }),
|
|
217
|
+
[]
|
|
218
|
+
);
|
|
219
|
+
const qk = useMemo(() => {
|
|
220
|
+
const adapter = new DrizzleAdapter();
|
|
221
|
+
const iDrizzleAdataperOptions: IDrizzleAdapterOptions = {
|
|
222
|
+
db: db as unknown as PGlite,
|
|
223
|
+
schema: { tasks } as unknown as Record<string, Record<string, SQLWrapper>>
|
|
224
|
+
};
|
|
225
|
+
adapter.initialize(iDrizzleAdataperOptions);
|
|
226
|
+
return createQueryKit({
|
|
227
|
+
adapter,
|
|
228
|
+
schema: { tasks } as unknown as Record<string, Record<string, SQLWrapper>>
|
|
229
|
+
});
|
|
230
|
+
}, [db]);
|
|
231
|
+
|
|
232
|
+
// Note: Execute via QueryKit fluent API (Drizzle adapter under the hood)
|
|
233
|
+
|
|
234
|
+
// Execute search function
|
|
235
|
+
const executeSearch = useCallback(
|
|
236
|
+
async (searchQuery: string) => {
|
|
237
|
+
if (!dbReady) return;
|
|
238
|
+
|
|
239
|
+
setIsSearching(true);
|
|
240
|
+
setQuery(searchQuery); // Keep the query in the input field
|
|
241
|
+
setIsSuggestionsOpen(false);
|
|
242
|
+
setActiveSuggestionIndex(-1);
|
|
243
|
+
setIsInputFocused(false);
|
|
244
|
+
inputRef.current?.blur();
|
|
245
|
+
setOperatorsUsed([]);
|
|
246
|
+
setExplainJson(null);
|
|
247
|
+
setPlanningTimeMs(null);
|
|
248
|
+
setExecutionTimeMs(null);
|
|
249
|
+
setExplainError(null);
|
|
250
|
+
setDbExecutionMs(null);
|
|
251
|
+
setParseTranslateMs(null);
|
|
252
|
+
setExplainLatencyMs(null);
|
|
253
|
+
setBaselineFetchMs(null);
|
|
254
|
+
const started = performance.now();
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
// Get all tasks count/base for messaging
|
|
258
|
+
const baselineStart = performance.now();
|
|
259
|
+
const allTasks = await db.select().from(tasks);
|
|
260
|
+
setBaselineFetchMs(performance.now() - baselineStart);
|
|
261
|
+
setRowsScanned(allTasks.length);
|
|
262
|
+
|
|
263
|
+
// Use QueryKit fluent API to execute the query
|
|
264
|
+
let filteredTasks: Task[] = allTasks as Task[];
|
|
265
|
+
let wasQueryKitUsed = false;
|
|
266
|
+
if (searchQuery.trim()) {
|
|
267
|
+
try {
|
|
268
|
+
filteredTasks = (await qk
|
|
269
|
+
.query('tasks')
|
|
270
|
+
.where(searchQuery)
|
|
271
|
+
.execute()) as Task[];
|
|
272
|
+
wasQueryKitUsed = true;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.warn(
|
|
275
|
+
'Query execution failed, falling back to simple search:',
|
|
276
|
+
error
|
|
277
|
+
);
|
|
278
|
+
const searchTerm = searchQuery.toLowerCase();
|
|
279
|
+
filteredTasks = (allTasks as Task[]).filter(
|
|
280
|
+
task =>
|
|
281
|
+
task.title.toLowerCase().includes(searchTerm) ||
|
|
282
|
+
task.status.toLowerCase().includes(searchTerm)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Generate SQL from QueryKit for display
|
|
288
|
+
let mockSQL = 'SELECT * FROM tasks';
|
|
289
|
+
let detectedOperators: string[] = [];
|
|
290
|
+
let whereSql: string | null = null;
|
|
291
|
+
if (searchQuery.trim()) {
|
|
292
|
+
try {
|
|
293
|
+
const parseStart = performance.now();
|
|
294
|
+
const ast = parser.parse(searchQuery);
|
|
295
|
+
const translated = sqlTranslator.translate(ast) as
|
|
296
|
+
| string
|
|
297
|
+
| { sql: string; params: unknown[] };
|
|
298
|
+
setParseTranslateMs(performance.now() - parseStart);
|
|
299
|
+
whereSql =
|
|
300
|
+
typeof translated === 'string' ? translated : translated.sql;
|
|
301
|
+
mockSQL += ` WHERE ${whereSql}`;
|
|
302
|
+
// Robust operator detection with word boundaries and precedence
|
|
303
|
+
const extractOperators = (sqlText: string): string[] => {
|
|
304
|
+
const found = new Set<string>();
|
|
305
|
+
const upper = sqlText.toUpperCase();
|
|
306
|
+
// Keyword operators (use word boundaries)
|
|
307
|
+
const keywordOps: Array<[string, RegExp]> = [
|
|
308
|
+
['ILIKE', /\bILIKE\b/i],
|
|
309
|
+
['LIKE', /\bLIKE\b/i],
|
|
310
|
+
['AND', /\bAND\b/i],
|
|
311
|
+
['OR', /\bOR\b/i],
|
|
312
|
+
['NOT', /\bNOT\b/i],
|
|
313
|
+
['IN', /\bIN\b/i],
|
|
314
|
+
['BETWEEN', /\bBETWEEN\b/i]
|
|
315
|
+
];
|
|
316
|
+
for (const [name, re] of keywordOps) {
|
|
317
|
+
if (re.test(sqlText)) found.add(name);
|
|
318
|
+
}
|
|
319
|
+
// Symbol operators: match longest first and remove before shorter matches
|
|
320
|
+
let temp = upper;
|
|
321
|
+
const consume = (re: RegExp, label: string): void => {
|
|
322
|
+
if (re.test(temp)) {
|
|
323
|
+
found.add(label);
|
|
324
|
+
temp = temp.replace(re, ' ');
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
consume(/>=/g, '>=');
|
|
328
|
+
consume(/<=/g, '<=');
|
|
329
|
+
consume(/!=/g, '!=');
|
|
330
|
+
consume(/=/g, '=');
|
|
331
|
+
consume(/>/g, '>');
|
|
332
|
+
consume(/</g, '<');
|
|
333
|
+
return Array.from(found);
|
|
334
|
+
};
|
|
335
|
+
detectedOperators = extractOperators(whereSql);
|
|
336
|
+
} catch {
|
|
337
|
+
mockSQL += ` WHERE title ILIKE '%${searchQuery}%' OR status ILIKE '%${searchQuery}%'`;
|
|
338
|
+
detectedOperators = ['ILIKE'];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setResults(filteredTasks);
|
|
343
|
+
setLastExecutedQuery(searchQuery.trim() ? searchQuery : '(default)');
|
|
344
|
+
setGeneratedSQL(mockSQL);
|
|
345
|
+
setUsedQueryKit(wasQueryKitUsed);
|
|
346
|
+
setOperatorsUsed(Array.from(new Set(detectedOperators)));
|
|
347
|
+
|
|
348
|
+
// Try to run EXPLAIN ANALYZE to capture a plan (JSON format for easy parsing)
|
|
349
|
+
try {
|
|
350
|
+
const fullSql = mockSQL;
|
|
351
|
+
// Drizzle execute with raw SQL. PGlite should support EXPLAIN.
|
|
352
|
+
const explainCmd = `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${fullSql}`;
|
|
353
|
+
const explainStart = performance.now();
|
|
354
|
+
const explainRows = await db.execute(sql.raw(explainCmd));
|
|
355
|
+
setExplainLatencyMs(performance.now() - explainStart);
|
|
356
|
+
// Shape can vary. Try common Postgres JSON format: [{ "QUERY PLAN": [ { Plan: {...}, Planning Time: n, Execution Time: n } ] }]
|
|
357
|
+
const firstRow = Array.isArray(explainRows)
|
|
358
|
+
? explainRows[0]
|
|
359
|
+
: (explainRows?.rows?.[0] ?? explainRows);
|
|
360
|
+
const planContainer =
|
|
361
|
+
firstRow?.['QUERY PLAN'] ?? firstRow?.query_plan ?? firstRow;
|
|
362
|
+
const jsonRoot = Array.isArray(planContainer)
|
|
363
|
+
? planContainer[0]
|
|
364
|
+
: planContainer;
|
|
365
|
+
if (jsonRoot) {
|
|
366
|
+
const planning =
|
|
367
|
+
typeof jsonRoot['Planning Time'] === 'number'
|
|
368
|
+
? jsonRoot['Planning Time']
|
|
369
|
+
: (jsonRoot?.PlanningTime ?? null);
|
|
370
|
+
const execution =
|
|
371
|
+
typeof jsonRoot['Execution Time'] === 'number'
|
|
372
|
+
? jsonRoot['Execution Time']
|
|
373
|
+
: (jsonRoot?.ExecutionTime ?? null);
|
|
374
|
+
if (typeof planning === 'number') setPlanningTimeMs(planning);
|
|
375
|
+
if (typeof execution === 'number') setExecutionTimeMs(execution);
|
|
376
|
+
if (typeof execution === 'number') setDbExecutionMs(execution);
|
|
377
|
+
setExplainJson(JSON.stringify(jsonRoot, null, 2));
|
|
378
|
+
} else {
|
|
379
|
+
// Some drivers return text rows when FORMAT JSON is not supported
|
|
380
|
+
const textPlan =
|
|
381
|
+
firstRow?.['QUERY PLAN'] ??
|
|
382
|
+
firstRow?.explain ??
|
|
383
|
+
String(explainRows ?? '');
|
|
384
|
+
setExplainJson(
|
|
385
|
+
typeof textPlan === 'string'
|
|
386
|
+
? textPlan
|
|
387
|
+
: JSON.stringify(textPlan, null, 2)
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
setExplainError('EXPLAIN not available');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const elapsed = performance.now() - started;
|
|
395
|
+
setLastExecutionMs(elapsed);
|
|
396
|
+
|
|
397
|
+
if (searchQuery.trim()) {
|
|
398
|
+
toast(
|
|
399
|
+
`QueryKit parsed and filtered ${allTasks.length} rows → ${filteredTasks.length} results in ${elapsed.toFixed(1)} ms`
|
|
400
|
+
);
|
|
401
|
+
} else {
|
|
402
|
+
toast(`Showing all ${allTasks.length} rows`);
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
console.error('Search failed:', error);
|
|
406
|
+
toast('Search failed');
|
|
407
|
+
} finally {
|
|
408
|
+
setIsSearching(false);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
[dbReady, db, parser, sqlTranslator, qk]
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Handle input change (just updates query state)
|
|
415
|
+
const handleSearchChange = useCallback(
|
|
416
|
+
(value: string) => {
|
|
417
|
+
setQuery(value);
|
|
418
|
+
setIsSuggestionsOpen(true);
|
|
419
|
+
if (!isInputFocused) setIsInputFocused(true);
|
|
420
|
+
// Do not auto-select a suggestion when empty; allow user to choose via arrows
|
|
421
|
+
setActiveSuggestionIndex(-1);
|
|
422
|
+
},
|
|
423
|
+
[isInputFocused]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// Handle Enter key press to execute search
|
|
427
|
+
const handleKeyDown = useCallback(
|
|
428
|
+
(event: React.KeyboardEvent) => {
|
|
429
|
+
if (event.key === 'Escape') {
|
|
430
|
+
event.preventDefault();
|
|
431
|
+
setIsSuggestionsOpen(false);
|
|
432
|
+
setActiveSuggestionIndex(-1);
|
|
433
|
+
setIsInputFocused(false);
|
|
434
|
+
inputRef.current?.blur();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (event.key === 'ArrowDown') {
|
|
438
|
+
if (!isSuggestionsOpen) return;
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
setActiveSuggestionIndex(prev => {
|
|
441
|
+
const next = prev < suggestions.length - 1 ? prev + 1 : 0;
|
|
442
|
+
return next;
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (event.key === 'ArrowUp') {
|
|
447
|
+
if (!isSuggestionsOpen) return;
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
setActiveSuggestionIndex(prev => {
|
|
450
|
+
const next = prev > 0 ? prev - 1 : suggestions.length - 1;
|
|
451
|
+
return next;
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (event.key === 'Enter') {
|
|
456
|
+
event.preventDefault();
|
|
457
|
+
const trimmed = query.trim();
|
|
458
|
+
if (trimmed.length === 0) {
|
|
459
|
+
// Clear to default dataset
|
|
460
|
+
setIsSuggestionsOpen(false);
|
|
461
|
+
setActiveSuggestionIndex(-1);
|
|
462
|
+
void executeSearch('');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (isSuggestionsOpen && activeSuggestionIndex >= 0) {
|
|
466
|
+
const chosen = suggestions[activeSuggestionIndex]?.q ?? query;
|
|
467
|
+
setIsSuggestionsOpen(false);
|
|
468
|
+
setActiveSuggestionIndex(-1);
|
|
469
|
+
void executeSearch(chosen);
|
|
470
|
+
} else {
|
|
471
|
+
setIsSuggestionsOpen(false);
|
|
472
|
+
void executeSearch(trimmed);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
[
|
|
478
|
+
query,
|
|
479
|
+
executeSearch,
|
|
480
|
+
isSuggestionsOpen,
|
|
481
|
+
activeSuggestionIndex,
|
|
482
|
+
suggestions
|
|
483
|
+
]
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Copy SQL output
|
|
487
|
+
const handleCopySql = useCallback(async () => {
|
|
488
|
+
try {
|
|
489
|
+
const textToCopy = showExplain
|
|
490
|
+
? (explainJson ?? 'EXPLAIN not available')
|
|
491
|
+
: formattedSQL || generatedSQL;
|
|
492
|
+
await navigator.clipboard.writeText(textToCopy);
|
|
493
|
+
toast(
|
|
494
|
+
showExplain ? 'EXPLAIN copied to clipboard' : 'SQL copied to clipboard'
|
|
495
|
+
);
|
|
496
|
+
setHasCopiedSql(true);
|
|
497
|
+
window.setTimeout(() => setHasCopiedSql(false), COPY_FEEDBACK_MS);
|
|
498
|
+
} catch {
|
|
499
|
+
toast(showExplain ? 'Failed to copy EXPLAIN' : 'Failed to copy SQL');
|
|
500
|
+
}
|
|
501
|
+
}, [formattedSQL, generatedSQL, explainJson, showExplain]);
|
|
502
|
+
|
|
503
|
+
// Add Cmd+K keyboard shortcut to focus the inline input
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
const down = (e: KeyboardEvent): void => {
|
|
506
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
inputRef.current?.focus();
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
document.addEventListener('keydown', down);
|
|
513
|
+
return (): void => document.removeEventListener('keydown', down);
|
|
514
|
+
}, []);
|
|
515
|
+
|
|
516
|
+
// Measure the top of the SQL card to set drawer height (covers up to the card's top)
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
const measure = (): void => {
|
|
519
|
+
const el = sqlCardAnchorRef.current;
|
|
520
|
+
if (!el) return;
|
|
521
|
+
const rect = el.getBoundingClientRect();
|
|
522
|
+
const top = Math.max(0, rect.top);
|
|
523
|
+
setDrawerTopPx(top);
|
|
524
|
+
};
|
|
525
|
+
const onResize = (): number => requestAnimationFrame(measure);
|
|
526
|
+
measure();
|
|
527
|
+
window.addEventListener('resize', onResize);
|
|
528
|
+
return (): void => window.removeEventListener('resize', onResize);
|
|
529
|
+
}, [lastExecutedQuery]);
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<div className="relative min-h-[100svh] h-[100svh] flex flex-col items-center justify-start px-6 bg-transparent overflow-hidden pt-[10svh] sm:pt-[33svh]">
|
|
533
|
+
{/* Aurora background */}
|
|
534
|
+
<div className="fixed inset-0 -z-10 pointer-events-none">
|
|
535
|
+
<Aurora amplitude={1.0} blend={0.6} speed={0.4} />
|
|
536
|
+
</div>
|
|
537
|
+
{/* {isInputFocused && (
|
|
538
|
+
<div className="fixed inset-0 z-[5] pointer-events-none">
|
|
539
|
+
<LightRays
|
|
540
|
+
rayLength={3}
|
|
541
|
+
saturation={2}
|
|
542
|
+
lightSpread={.5}
|
|
543
|
+
raysColor="#fff"
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
)} */}
|
|
547
|
+
<div className="fixed inset-0 z-10 bg-background/60 transition-opacity pointer-events-none" />
|
|
548
|
+
<div className="relative z-20 w-full max-w-3xl space-y-6">
|
|
549
|
+
<div className="text-center mb-2">
|
|
550
|
+
<h1 className="text-4xl sm:text-5xl font-extrabold tracking-tight">
|
|
551
|
+
QueryKit
|
|
552
|
+
</h1>
|
|
553
|
+
<p className="text-base sm:text-lg text-muted-foreground mt-2">
|
|
554
|
+
Try filtering tasks with the QueryKit DSL. Press ⌘K to start.
|
|
555
|
+
</p>
|
|
556
|
+
</div>
|
|
557
|
+
{/* Inline search input with recommendation popover */}
|
|
558
|
+
<div className="relative z-50 w-full">
|
|
559
|
+
<div className="flex items-center gap-2 rounded-2xl border bg-background shadow-sm px-3 py-2">
|
|
560
|
+
<Search className="h-4 w-4 text-muted-foreground" />
|
|
561
|
+
<div className="relative w-full">
|
|
562
|
+
{/* Highlight overlay behind the input */}
|
|
563
|
+
{query && (
|
|
564
|
+
<div
|
|
565
|
+
aria-hidden
|
|
566
|
+
className="pointer-events-none absolute inset-0 z-0 whitespace-pre text-sm leading-[1.25rem] text-foreground/90 flex items-center"
|
|
567
|
+
dangerouslySetInnerHTML={{
|
|
568
|
+
__html: highlightQueryHtml(query)
|
|
569
|
+
}}
|
|
570
|
+
/>
|
|
571
|
+
)}
|
|
572
|
+
<input
|
|
573
|
+
ref={inputRef}
|
|
574
|
+
type="text"
|
|
575
|
+
value={query}
|
|
576
|
+
onChange={e => handleSearchChange(e.target.value)}
|
|
577
|
+
onKeyDown={handleKeyDown}
|
|
578
|
+
onFocus={() => {
|
|
579
|
+
setIsInputFocused(true);
|
|
580
|
+
setIsSuggestionsOpen(true);
|
|
581
|
+
}}
|
|
582
|
+
onBlur={e => {
|
|
583
|
+
const target = e.currentTarget;
|
|
584
|
+
// Defer blur closing to allow click on suggestions
|
|
585
|
+
setTimeout(() => {
|
|
586
|
+
const nextActive =
|
|
587
|
+
document.activeElement as HTMLElement | null;
|
|
588
|
+
if (
|
|
589
|
+
!target ||
|
|
590
|
+
!nextActive ||
|
|
591
|
+
!target.contains(nextActive)
|
|
592
|
+
) {
|
|
593
|
+
setIsInputFocused(false);
|
|
594
|
+
setIsSuggestionsOpen(false);
|
|
595
|
+
}
|
|
596
|
+
}, 0);
|
|
597
|
+
}}
|
|
598
|
+
placeholder="Search tasks with QueryKit (Press Enter to search)"
|
|
599
|
+
className={`placeholder:text-muted-foreground relative z-10 flex h-10 w-full rounded-md bg-transparent text-sm leading-[1.25rem] outline-none ${query ? 'text-transparent caret-foreground' : ''}`}
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
<kbd className="bg-muted text-muted-foreground pointer-events-none inline-flex h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none">
|
|
603
|
+
<span className="text-xs">⌘</span>K
|
|
604
|
+
</kbd>
|
|
605
|
+
</div>
|
|
606
|
+
{/* Simple recommendations popover */}
|
|
607
|
+
{isSuggestionsOpen && (
|
|
608
|
+
<div className="absolute left-0 right-0 z-[60] mt-2 rounded-md border bg-popover text-popover-foreground shadow-md">
|
|
609
|
+
<div className="px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground border-b">
|
|
610
|
+
QueryKit Examples
|
|
611
|
+
</div>
|
|
612
|
+
<ul
|
|
613
|
+
className="max-h-72 overflow-y-auto py-1"
|
|
614
|
+
role="listbox"
|
|
615
|
+
aria-label="Query suggestions"
|
|
616
|
+
>
|
|
617
|
+
{suggestions.map((s, idx) => (
|
|
618
|
+
<li
|
|
619
|
+
key={s.q}
|
|
620
|
+
role="option"
|
|
621
|
+
aria-selected={activeSuggestionIndex === idx}
|
|
622
|
+
className={`flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent ${activeSuggestionIndex === idx ? 'bg-accent' : ''}`}
|
|
623
|
+
onMouseDown={e => e.preventDefault()}
|
|
624
|
+
onMouseEnter={() => setActiveSuggestionIndex(idx)}
|
|
625
|
+
onClick={() => {
|
|
626
|
+
setIsSuggestionsOpen(false);
|
|
627
|
+
setActiveSuggestionIndex(-1);
|
|
628
|
+
executeSearch(s.q);
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
<span
|
|
632
|
+
className="text-sm"
|
|
633
|
+
dangerouslySetInnerHTML={{
|
|
634
|
+
__html: highlightQueryHtml(s.q)
|
|
635
|
+
}}
|
|
636
|
+
/>
|
|
637
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
638
|
+
{s.desc}
|
|
639
|
+
</span>
|
|
640
|
+
</li>
|
|
641
|
+
))}
|
|
642
|
+
</ul>
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{/* Query Card (no header) */}
|
|
648
|
+
<div ref={sqlCardAnchorRef} className="w-full">
|
|
649
|
+
<Card className="w-full">
|
|
650
|
+
{lastExecutedQuery && (
|
|
651
|
+
<CardContent>
|
|
652
|
+
<div className="relative bg-muted p-3 rounded-md">
|
|
653
|
+
<button
|
|
654
|
+
type="button"
|
|
655
|
+
onClick={() => setShowExplain(v => !v)}
|
|
656
|
+
aria-label={
|
|
657
|
+
showExplain ? 'Show SQL' : 'Show EXPLAIN ANALYZE'
|
|
658
|
+
}
|
|
659
|
+
title={showExplain ? 'Show SQL' : 'Show EXPLAIN ANALYZE'}
|
|
660
|
+
className={`absolute top-2 right-10 z-10 inline-flex items-center justify-center rounded-md hover:bg-accent/30 transition-colors h-7 w-7 ${showExplain ? 'bg-accent/40' : ''}`}
|
|
661
|
+
>
|
|
662
|
+
<SearchCode className="h-4 w-4" />
|
|
663
|
+
</button>
|
|
664
|
+
<button
|
|
665
|
+
type="button"
|
|
666
|
+
onClick={handleCopySql}
|
|
667
|
+
aria-label={showExplain ? 'Copy EXPLAIN' : 'Copy SQL'}
|
|
668
|
+
title={showExplain ? 'Copy EXPLAIN' : 'Copy SQL'}
|
|
669
|
+
className="absolute top-2 right-2 z-10 inline-flex items-center justify-center rounded-md hover:bg-accent/30 transition-colors h-7 w-7"
|
|
670
|
+
>
|
|
671
|
+
<Copy
|
|
672
|
+
className={`h-4 w-4 transition-all duration-200 ${hasCopiedSql ? 'opacity-0 scale-90' : 'opacity-100 scale-100'}`}
|
|
673
|
+
/>
|
|
674
|
+
<Check
|
|
675
|
+
className={`h-4 w-4 absolute transition-all duration-200 ${hasCopiedSql ? 'opacity-100 scale-100' : 'opacity-0 scale-110'}`}
|
|
676
|
+
/>
|
|
677
|
+
</button>
|
|
678
|
+
<SyntaxHighlighter
|
|
679
|
+
language={showExplain ? 'json' : 'sql'}
|
|
680
|
+
style={atomOneDarkReasonable}
|
|
681
|
+
customStyle={{ background: 'transparent', margin: 0 }}
|
|
682
|
+
wrapLongLines
|
|
683
|
+
>
|
|
684
|
+
{showExplain
|
|
685
|
+
? (explainJson ?? explainError ?? 'EXPLAIN not available')
|
|
686
|
+
: formattedSQL}
|
|
687
|
+
</SyntaxHighlighter>
|
|
688
|
+
</div>
|
|
689
|
+
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
|
690
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
691
|
+
<div className="text-xs text-muted-foreground">
|
|
692
|
+
DB time (EXPLAIN)
|
|
693
|
+
</div>
|
|
694
|
+
<div className="mt-1 text-base font-medium">
|
|
695
|
+
{dbExecutionMs !== null
|
|
696
|
+
? `${dbExecutionMs.toFixed(3)} ms`
|
|
697
|
+
: '-'}
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
701
|
+
<div className="text-xs text-muted-foreground">
|
|
702
|
+
Rows returned
|
|
703
|
+
</div>
|
|
704
|
+
<div className="mt-1 text-base font-medium">{`${results.length} of ${rowsScanned ?? '-'}`}</div>
|
|
705
|
+
</div>
|
|
706
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
707
|
+
<div className="text-xs text-muted-foreground">Engine</div>
|
|
708
|
+
<div className="mt-1">
|
|
709
|
+
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-muted">
|
|
710
|
+
{usedQueryKit
|
|
711
|
+
? 'QueryKit · Drizzle'
|
|
712
|
+
: 'Client fallback'}
|
|
713
|
+
</span>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
<div className="mt-3">
|
|
718
|
+
<div className="text-xs text-muted-foreground mb-1">
|
|
719
|
+
Detected operators
|
|
720
|
+
</div>
|
|
721
|
+
{operatorsUsed.length ? (
|
|
722
|
+
<div className="flex flex-wrap gap-2">
|
|
723
|
+
{operatorsUsed.map(op => (
|
|
724
|
+
<span
|
|
725
|
+
key={op}
|
|
726
|
+
className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium"
|
|
727
|
+
>
|
|
728
|
+
{op}
|
|
729
|
+
</span>
|
|
730
|
+
))}
|
|
731
|
+
</div>
|
|
732
|
+
) : (
|
|
733
|
+
<div className="text-xs text-muted-foreground">-</div>
|
|
734
|
+
)}
|
|
735
|
+
</div>
|
|
736
|
+
{/* EXPLAIN has been integrated into the SQL window via the SearchCode toggle */}
|
|
737
|
+
</CardContent>
|
|
738
|
+
)}
|
|
739
|
+
</Card>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
{/* Toggle button to open results drawer */}
|
|
744
|
+
<button
|
|
745
|
+
type="button"
|
|
746
|
+
onClick={() => setIsResultsOpen(v => !v)}
|
|
747
|
+
aria-expanded={isResultsOpen}
|
|
748
|
+
aria-controls="results-drawer"
|
|
749
|
+
title={isResultsOpen ? 'Hide results' : 'Show results'}
|
|
750
|
+
className="fixed bottom-10 left-1/2 -translate-x-1/2 z-[70] inline-flex items-center justify-center rounded-full border bg-background shadow px-3 py-2 text-xs hover:bg-accent transition-colors"
|
|
751
|
+
>
|
|
752
|
+
<ChevronUp
|
|
753
|
+
className={`h-4 w-4 transition-transform ${isResultsOpen ? 'rotate-180' : ''}`}
|
|
754
|
+
/>
|
|
755
|
+
<span className="ml-2">{isResultsOpen ? 'Hide' : 'Results'}</span>
|
|
756
|
+
</button>
|
|
757
|
+
|
|
758
|
+
{/* Bottom drawer with results table */}
|
|
759
|
+
{isResultsOpen && (
|
|
760
|
+
<div
|
|
761
|
+
className="fixed inset-0 z-[55]"
|
|
762
|
+
onClick={() => setIsResultsOpen(false)}
|
|
763
|
+
/>
|
|
764
|
+
)}
|
|
765
|
+
|
|
766
|
+
<div
|
|
767
|
+
id="results-drawer"
|
|
768
|
+
role="dialog"
|
|
769
|
+
aria-label="Query results"
|
|
770
|
+
className={`fixed left-0 right-0 z-[60] transition-transform duration-300 ease-out ${isResultsOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
|
771
|
+
style={{
|
|
772
|
+
pointerEvents: isResultsOpen ? 'auto' : 'none',
|
|
773
|
+
top: `${drawerTopPx}px`,
|
|
774
|
+
bottom: '12px'
|
|
775
|
+
}}
|
|
776
|
+
>
|
|
777
|
+
<div
|
|
778
|
+
className="mx-auto w-full max-w-3xl rounded-2xl border bg-background shadow-xl flex flex-col"
|
|
779
|
+
style={{ height: `calc(100svh - ${drawerTopPx}px - 12px)` }}
|
|
780
|
+
>
|
|
781
|
+
<div className="p-3 border-b flex items-center justify-between shrink-0">
|
|
782
|
+
<div className="text-sm font-medium">Results</div>
|
|
783
|
+
<div className="text-xs text-muted-foreground">
|
|
784
|
+
{isSearching ? 'Searching…' : `${results.length} rows`}
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
<div className="p-3 overflow-auto flex-1">
|
|
788
|
+
<Table>
|
|
789
|
+
<TableHeader>
|
|
790
|
+
<TableRow>
|
|
791
|
+
<TableHead className="w-[80px]">ID</TableHead>
|
|
792
|
+
<TableHead>Title</TableHead>
|
|
793
|
+
<TableHead>Status</TableHead>
|
|
794
|
+
<TableHead className="text-right">Priority</TableHead>
|
|
795
|
+
</TableRow>
|
|
796
|
+
</TableHeader>
|
|
797
|
+
<TableBody>
|
|
798
|
+
{results.map(t => (
|
|
799
|
+
<TableRow key={t.id}>
|
|
800
|
+
<TableCell>{t.id}</TableCell>
|
|
801
|
+
<TableCell>{t.title}</TableCell>
|
|
802
|
+
<TableCell>{t.status}</TableCell>
|
|
803
|
+
<TableCell className="text-right">{t.priority}</TableCell>
|
|
804
|
+
</TableRow>
|
|
805
|
+
))}
|
|
806
|
+
</TableBody>
|
|
807
|
+
</Table>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
);
|
|
813
|
+
}
|