@gblikas/querykit 0.0.0 → 0.2.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/.github/workflows/publish.yml +5 -7
- package/README.md +76 -0
- package/dist/parser/parser.d.ts +34 -0
- package/dist/parser/parser.js +164 -6
- package/dist/security/types.d.ts +48 -0
- package/dist/security/types.js +2 -0
- package/dist/security/validator.d.ts +35 -0
- package/dist/security/validator.js +108 -0
- package/examples/qk-next/app/globals.css +23 -0
- package/examples/qk-next/app/hooks/use-viewport-info.ts +89 -0
- package/examples/qk-next/app/layout.tsx +26 -7
- package/examples/qk-next/app/page.tsx +423 -121
- package/examples/qk-next/lib/utils.ts +74 -0
- package/examples/qk-next/package.json +5 -3
- package/examples/qk-next/pnpm-lock.yaml +112 -47
- package/package.json +5 -1
- package/src/parser/parser.test.ts +209 -1
- package/src/parser/parser.ts +234 -25
- package/src/security/types.ts +52 -0
- package/src/security/validator.test.ts +368 -0
- package/src/security/validator.ts +117 -0
|
@@ -21,14 +21,27 @@ import {
|
|
|
21
21
|
createQueryKit,
|
|
22
22
|
IDrizzleAdapterOptions
|
|
23
23
|
} from '@gblikas/querykit';
|
|
24
|
-
import { Copy, Check, Search,
|
|
24
|
+
import { Copy, Check, Search, ChevronUp, FileCode, X } from 'lucide-react';
|
|
25
25
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
26
26
|
import { atomOneDarkReasonable } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
|
27
27
|
import sqlLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
|
|
28
|
-
import
|
|
28
|
+
import typescriptLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/typescript';
|
|
29
|
+
import bashLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/bash';
|
|
30
|
+
// json language no longer needed as EXPLAIN view is removed
|
|
29
31
|
import { toast } from 'sonner';
|
|
30
32
|
import Aurora from '@/components/reactbits/blocks/Backgrounds/Aurora/Aurora';
|
|
31
33
|
import { PGlite } from '@electric-sql/pglite';
|
|
34
|
+
import { useViewportInfo } from './hooks/use-viewport-info';
|
|
35
|
+
import { cn, trackQueryKitIssue, trackQueryKitUsage, trackQueryKitSpeed } from '@/lib/utils';
|
|
36
|
+
import {
|
|
37
|
+
Drawer,
|
|
38
|
+
DrawerTrigger,
|
|
39
|
+
DrawerContent,
|
|
40
|
+
DrawerHeader,
|
|
41
|
+
DrawerTitle,
|
|
42
|
+
DrawerDescription,
|
|
43
|
+
DrawerClose
|
|
44
|
+
} from '@/components/ui/drawer';
|
|
32
45
|
|
|
33
46
|
// Simple GitHub-like highlighter for key:value tokens that colors only the value
|
|
34
47
|
const escapeHtml = (input: string): string =>
|
|
@@ -57,6 +70,43 @@ const highlightQueryHtml = (input: string): string => {
|
|
|
57
70
|
.join('');
|
|
58
71
|
};
|
|
59
72
|
|
|
73
|
+
const INSTALL_SNIPPET = `pnpm i @gblikas/querykit drizzle-orm`;
|
|
74
|
+
|
|
75
|
+
const SCHEMA_SNIPPET = `// schema.ts
|
|
76
|
+
import { serial, text, pgTable } from 'drizzle-orm/pg-core';
|
|
77
|
+
import { InferSelectModel } from 'drizzle-orm';
|
|
78
|
+
|
|
79
|
+
export const users = pgTable('users', {
|
|
80
|
+
id: serial('id').primaryKey(),
|
|
81
|
+
name: text('name').notNull(),
|
|
82
|
+
status: text('status').notNull().default('draft'),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export type SelectUser = InferSelectModel<typeof users>;
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const QUERYKIT_SNIPPET = `// querykit.ts
|
|
89
|
+
import { createQueryKit } from 'querykit';
|
|
90
|
+
import { drizzleAdapter } from 'querykit/adapters/drizzle';
|
|
91
|
+
import { users } from './schema';
|
|
92
|
+
|
|
93
|
+
export const qk = createQueryKit({
|
|
94
|
+
adapter: drizzleAdapter,
|
|
95
|
+
schema: { users },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// example.ts
|
|
99
|
+
import { qk } from './querykit';
|
|
100
|
+
|
|
101
|
+
const query = qk
|
|
102
|
+
.query('users')
|
|
103
|
+
.where('status:done AND name:"John *"')
|
|
104
|
+
.orderBy('name', 'asc')
|
|
105
|
+
.limit(10);
|
|
106
|
+
|
|
107
|
+
const results = await query.execute();
|
|
108
|
+
`;
|
|
109
|
+
|
|
60
110
|
const tasks = pgTable('tasks', {
|
|
61
111
|
id: serial('id').primaryKey(),
|
|
62
112
|
title: text('title').notNull(),
|
|
@@ -83,30 +133,77 @@ export default function Home(): JSX.Element {
|
|
|
83
133
|
const [rowsScanned, setRowsScanned] = useState<number | null>(null);
|
|
84
134
|
const [operatorsUsed, setOperatorsUsed] = useState<string[]>([]);
|
|
85
135
|
const [usedQueryKit, setUsedQueryKit] = useState<boolean>(false);
|
|
86
|
-
const [
|
|
136
|
+
const [, setExplainJson] = useState<string | null>(null);
|
|
87
137
|
const [, setPlanningTimeMs] = useState<number | null>(null);
|
|
88
138
|
const [, setExecutionTimeMs] = useState<number | null>(null);
|
|
89
|
-
const [
|
|
139
|
+
const [, setExplainError] = useState<string | null>(null);
|
|
90
140
|
const [dbExecutionMs, setDbExecutionMs] = useState<number | null>(null);
|
|
91
141
|
const [, setParseTranslateMs] = useState<number | null>(null);
|
|
92
142
|
const [, setExplainLatencyMs] = useState<number | null>(null);
|
|
93
143
|
const [, setBaselineFetchMs] = useState<number | null>(null);
|
|
94
144
|
const [hasCopiedSql, setHasCopiedSql] = useState(false);
|
|
95
|
-
const [
|
|
145
|
+
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
|
|
146
|
+
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
147
|
+
// EXPLAIN view removed
|
|
96
148
|
const [isResultsOpen, setIsResultsOpen] = useState(false);
|
|
97
149
|
const sqlCardAnchorRef = useRef<HTMLDivElement | null>(null);
|
|
98
150
|
const [drawerTopPx, setDrawerTopPx] = useState<number>(0);
|
|
151
|
+
const cardContentRef = useRef<HTMLDivElement | null>(null);
|
|
152
|
+
const [cardMaxHeightPx, setCardMaxHeightPx] = useState<number | null>(null);
|
|
99
153
|
const COPY_FEEDBACK_MS = 2000;
|
|
100
154
|
|
|
155
|
+
const quickStartSections = useMemo(
|
|
156
|
+
() => [
|
|
157
|
+
{
|
|
158
|
+
id: 'install',
|
|
159
|
+
title: 'Install QueryKit',
|
|
160
|
+
description:
|
|
161
|
+
'Pull in QueryKit and the Drizzle adapter so you can follow along locally.',
|
|
162
|
+
code: INSTALL_SNIPPET,
|
|
163
|
+
language: 'bash'
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'schema',
|
|
167
|
+
title: 'Define your schema (schema.ts)',
|
|
168
|
+
description:
|
|
169
|
+
'Describe the table you want to search—QueryKit uses this shape to parse queries.',
|
|
170
|
+
code: SCHEMA_SNIPPET,
|
|
171
|
+
language: 'typescript'
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'usage',
|
|
175
|
+
title: 'Create QueryKit and run a search',
|
|
176
|
+
description:
|
|
177
|
+
'Instantiate QueryKit, build a Lucene-style query, and execute it against your DB.',
|
|
178
|
+
code: QUERYKIT_SNIPPET,
|
|
179
|
+
language: 'typescript'
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
[]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Viewport info for small-height detection
|
|
186
|
+
const { isShortSideLessThan } = useViewportInfo();
|
|
187
|
+
const isShortViewport = isShortSideLessThan(390);
|
|
188
|
+
|
|
189
|
+
// Respect a ?drawer=open query param so the quick-start drawer can be shared
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (typeof window === 'undefined') return;
|
|
192
|
+
const search = new URLSearchParams(window.location.search);
|
|
193
|
+
if (search.get('drawer') === 'open') {
|
|
194
|
+
setIsDrawerOpen(true);
|
|
195
|
+
}
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
101
198
|
// Register languages once
|
|
102
199
|
useEffect(() => {
|
|
103
200
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
(
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
const highlighter = SyntaxHighlighter as typeof SyntaxHighlighter & {
|
|
202
|
+
registerLanguage: (name: string, language: unknown) => void;
|
|
203
|
+
};
|
|
204
|
+
highlighter.registerLanguage('sql', sqlLanguage);
|
|
205
|
+
highlighter.registerLanguage('typescript', typescriptLanguage);
|
|
206
|
+
highlighter.registerLanguage('bash', bashLanguage);
|
|
110
207
|
} catch (error) {
|
|
111
208
|
console.error('Failed to register languages:', error);
|
|
112
209
|
}
|
|
@@ -201,6 +298,7 @@ export default function Home(): JSX.Element {
|
|
|
201
298
|
// Load initial data and show default query details
|
|
202
299
|
const data = await db.select().from(tasks);
|
|
203
300
|
setResults(data as Task[]);
|
|
301
|
+
setRowsScanned(data.length);
|
|
204
302
|
setLastExecutedQuery('(default)');
|
|
205
303
|
setGeneratedSQL('SELECT * FROM tasks');
|
|
206
304
|
setDbReady(true);
|
|
@@ -252,13 +350,20 @@ export default function Home(): JSX.Element {
|
|
|
252
350
|
setExplainLatencyMs(null);
|
|
253
351
|
setBaselineFetchMs(null);
|
|
254
352
|
const started = performance.now();
|
|
353
|
+
let baselineMs: number | null = null;
|
|
354
|
+
let localParseTranslateMs: number | null = null;
|
|
355
|
+
let localExplainLatencyMs: number | null = null;
|
|
356
|
+
let localDbExecutionMs: number | null = null;
|
|
357
|
+
let localRowsScanned: number | null = null;
|
|
255
358
|
|
|
256
359
|
try {
|
|
257
360
|
// Get all tasks count/base for messaging
|
|
258
361
|
const baselineStart = performance.now();
|
|
259
362
|
const allTasks = await db.select().from(tasks);
|
|
260
|
-
|
|
261
|
-
|
|
363
|
+
baselineMs = performance.now() - baselineStart;
|
|
364
|
+
setBaselineFetchMs(baselineMs);
|
|
365
|
+
localRowsScanned = allTasks.length;
|
|
366
|
+
setRowsScanned(localRowsScanned);
|
|
262
367
|
|
|
263
368
|
// Use QueryKit fluent API to execute the query
|
|
264
369
|
let filteredTasks: Task[] = allTasks as Task[];
|
|
@@ -275,6 +380,12 @@ export default function Home(): JSX.Element {
|
|
|
275
380
|
'Query execution failed, falling back to simple search:',
|
|
276
381
|
error
|
|
277
382
|
);
|
|
383
|
+
void trackQueryKitIssue({
|
|
384
|
+
errorName: (error as Error)?.name ?? 'UnknownError',
|
|
385
|
+
errorMessage: (error as Error)?.message ?? 'Unknown',
|
|
386
|
+
stage: 'execute',
|
|
387
|
+
query: searchQuery
|
|
388
|
+
});
|
|
278
389
|
const searchTerm = searchQuery.toLowerCase();
|
|
279
390
|
filteredTasks = (allTasks as Task[]).filter(
|
|
280
391
|
task =>
|
|
@@ -295,7 +406,8 @@ export default function Home(): JSX.Element {
|
|
|
295
406
|
const translated = sqlTranslator.translate(ast) as
|
|
296
407
|
| string
|
|
297
408
|
| { sql: string; params: unknown[] };
|
|
298
|
-
|
|
409
|
+
localParseTranslateMs = performance.now() - parseStart;
|
|
410
|
+
setParseTranslateMs(localParseTranslateMs);
|
|
299
411
|
whereSql =
|
|
300
412
|
typeof translated === 'string' ? translated : translated.sql;
|
|
301
413
|
mockSQL += ` WHERE ${whereSql}`;
|
|
@@ -333,7 +445,13 @@ export default function Home(): JSX.Element {
|
|
|
333
445
|
return Array.from(found);
|
|
334
446
|
};
|
|
335
447
|
detectedOperators = extractOperators(whereSql);
|
|
336
|
-
} catch {
|
|
448
|
+
} catch (error) {
|
|
449
|
+
void trackQueryKitIssue({
|
|
450
|
+
errorName: (error as Error)?.name ?? 'UnknownError',
|
|
451
|
+
errorMessage: (error as Error)?.message ?? 'Unknown',
|
|
452
|
+
stage: 'translate',
|
|
453
|
+
query: searchQuery
|
|
454
|
+
});
|
|
337
455
|
mockSQL += ` WHERE title ILIKE '%${searchQuery}%' OR status ILIKE '%${searchQuery}%'`;
|
|
338
456
|
detectedOperators = ['ILIKE'];
|
|
339
457
|
}
|
|
@@ -343,7 +461,12 @@ export default function Home(): JSX.Element {
|
|
|
343
461
|
setLastExecutedQuery(searchQuery.trim() ? searchQuery : '(default)');
|
|
344
462
|
setGeneratedSQL(mockSQL);
|
|
345
463
|
setUsedQueryKit(wasQueryKitUsed);
|
|
346
|
-
|
|
464
|
+
const uniqueOperators = Array.from(new Set(detectedOperators));
|
|
465
|
+
void trackQueryKitUsage({
|
|
466
|
+
usedQueryKit: wasQueryKitUsed,
|
|
467
|
+
operators: uniqueOperators
|
|
468
|
+
});
|
|
469
|
+
setOperatorsUsed(uniqueOperators);
|
|
347
470
|
|
|
348
471
|
// Try to run EXPLAIN ANALYZE to capture a plan (JSON format for easy parsing)
|
|
349
472
|
try {
|
|
@@ -352,7 +475,8 @@ export default function Home(): JSX.Element {
|
|
|
352
475
|
const explainCmd = `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${fullSql}`;
|
|
353
476
|
const explainStart = performance.now();
|
|
354
477
|
const explainRows = await db.execute(sql.raw(explainCmd));
|
|
355
|
-
|
|
478
|
+
localExplainLatencyMs = performance.now() - explainStart;
|
|
479
|
+
setExplainLatencyMs(localExplainLatencyMs);
|
|
356
480
|
// Shape can vary. Try common Postgres JSON format: [{ "QUERY PLAN": [ { Plan: {...}, Planning Time: n, Execution Time: n } ] }]
|
|
357
481
|
const firstRow = Array.isArray(explainRows)
|
|
358
482
|
? explainRows[0]
|
|
@@ -373,7 +497,10 @@ export default function Home(): JSX.Element {
|
|
|
373
497
|
: (jsonRoot?.ExecutionTime ?? null);
|
|
374
498
|
if (typeof planning === 'number') setPlanningTimeMs(planning);
|
|
375
499
|
if (typeof execution === 'number') setExecutionTimeMs(execution);
|
|
376
|
-
if (typeof execution === 'number')
|
|
500
|
+
if (typeof execution === 'number') {
|
|
501
|
+
localDbExecutionMs = execution;
|
|
502
|
+
setDbExecutionMs(execution);
|
|
503
|
+
}
|
|
377
504
|
setExplainJson(JSON.stringify(jsonRoot, null, 2));
|
|
378
505
|
} else {
|
|
379
506
|
// Some drivers return text rows when FORMAT JSON is not supported
|
|
@@ -387,12 +514,28 @@ export default function Home(): JSX.Element {
|
|
|
387
514
|
: JSON.stringify(textPlan, null, 2)
|
|
388
515
|
);
|
|
389
516
|
}
|
|
390
|
-
} catch {
|
|
517
|
+
} catch (error) {
|
|
518
|
+
void trackQueryKitIssue({
|
|
519
|
+
errorName: (error as Error)?.name ?? 'UnknownError',
|
|
520
|
+
errorMessage: (error as Error)?.message ?? 'Unknown',
|
|
521
|
+
stage: 'explain'
|
|
522
|
+
});
|
|
391
523
|
setExplainError('EXPLAIN not available');
|
|
392
524
|
}
|
|
393
525
|
|
|
394
526
|
const elapsed = performance.now() - started;
|
|
395
527
|
setLastExecutionMs(elapsed);
|
|
528
|
+
// Emit speed telemetry
|
|
529
|
+
void trackQueryKitSpeed({
|
|
530
|
+
usedQueryKit: wasQueryKitUsed,
|
|
531
|
+
baselineMs,
|
|
532
|
+
parseTranslateMs: localParseTranslateMs,
|
|
533
|
+
explainLatencyMs: localExplainLatencyMs,
|
|
534
|
+
dbExecutionMs: localDbExecutionMs,
|
|
535
|
+
totalMs: elapsed,
|
|
536
|
+
rowsScanned: localRowsScanned,
|
|
537
|
+
results: filteredTasks.length
|
|
538
|
+
});
|
|
396
539
|
|
|
397
540
|
if (searchQuery.trim()) {
|
|
398
541
|
toast(
|
|
@@ -403,6 +546,11 @@ export default function Home(): JSX.Element {
|
|
|
403
546
|
}
|
|
404
547
|
} catch (error) {
|
|
405
548
|
console.error('Search failed:', error);
|
|
549
|
+
void trackQueryKitIssue({
|
|
550
|
+
errorName: (error as Error)?.name ?? 'UnknownError',
|
|
551
|
+
errorMessage: (error as Error)?.message ?? 'Unknown',
|
|
552
|
+
stage: 'search'
|
|
553
|
+
});
|
|
406
554
|
toast('Search failed');
|
|
407
555
|
} finally {
|
|
408
556
|
setIsSearching(false);
|
|
@@ -486,19 +634,29 @@ export default function Home(): JSX.Element {
|
|
|
486
634
|
// Copy SQL output
|
|
487
635
|
const handleCopySql = useCallback(async () => {
|
|
488
636
|
try {
|
|
489
|
-
const textToCopy =
|
|
490
|
-
? (explainJson ?? 'EXPLAIN not available')
|
|
491
|
-
: formattedSQL || generatedSQL;
|
|
637
|
+
const textToCopy = formattedSQL || generatedSQL;
|
|
492
638
|
await navigator.clipboard.writeText(textToCopy);
|
|
493
|
-
toast(
|
|
494
|
-
showExplain ? 'EXPLAIN copied to clipboard' : 'SQL copied to clipboard'
|
|
495
|
-
);
|
|
639
|
+
toast('SQL copied to clipboard');
|
|
496
640
|
setHasCopiedSql(true);
|
|
497
641
|
window.setTimeout(() => setHasCopiedSql(false), COPY_FEEDBACK_MS);
|
|
498
642
|
} catch {
|
|
499
|
-
toast(
|
|
643
|
+
toast('Failed to copy SQL');
|
|
500
644
|
}
|
|
501
|
-
}, [formattedSQL, generatedSQL
|
|
645
|
+
}, [formattedSQL, generatedSQL]);
|
|
646
|
+
|
|
647
|
+
const handleCopySnippet = useCallback(
|
|
648
|
+
async (id: string, content: string) => {
|
|
649
|
+
try {
|
|
650
|
+
await navigator.clipboard.writeText(content);
|
|
651
|
+
toast('Copied to clipboard');
|
|
652
|
+
setCopiedSnippet(id);
|
|
653
|
+
window.setTimeout(() => setCopiedSnippet(null), COPY_FEEDBACK_MS);
|
|
654
|
+
} catch {
|
|
655
|
+
toast('Failed to copy code');
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
[COPY_FEEDBACK_MS]
|
|
659
|
+
);
|
|
502
660
|
|
|
503
661
|
// Add Cmd+K keyboard shortcut to focus the inline input
|
|
504
662
|
useEffect(() => {
|
|
@@ -528,8 +686,29 @@ export default function Home(): JSX.Element {
|
|
|
528
686
|
return (): void => window.removeEventListener('resize', onResize);
|
|
529
687
|
}, [lastExecutedQuery]);
|
|
530
688
|
|
|
689
|
+
// Cap the entire card content so its bottom does not go past the viewport.
|
|
690
|
+
// The SQL/EXPLAIN viewer becomes the scrollable area that flexes.
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
const measureCard = (): void => {
|
|
693
|
+
const el = cardContentRef.current;
|
|
694
|
+
if (!el) return;
|
|
695
|
+
const rect = el.getBoundingClientRect();
|
|
696
|
+
// Align bottom with the floating "Results" button which uses bottom-10 (2.5rem = 40px)
|
|
697
|
+
const RESULTS_BUTTON_BOTTOM_PX = 40;
|
|
698
|
+
const available = Math.max(
|
|
699
|
+
160,
|
|
700
|
+
window.innerHeight - rect.top - RESULTS_BUTTON_BOTTOM_PX
|
|
701
|
+
);
|
|
702
|
+
setCardMaxHeightPx(available);
|
|
703
|
+
};
|
|
704
|
+
const onResize = (): number => requestAnimationFrame(measureCard);
|
|
705
|
+
measureCard();
|
|
706
|
+
window.addEventListener('resize', onResize);
|
|
707
|
+
return (): void => window.removeEventListener('resize', onResize);
|
|
708
|
+
}, [lastExecutedQuery, results.length]);
|
|
709
|
+
|
|
531
710
|
return (
|
|
532
|
-
<div className="relative min-h-[100svh] h-[100svh] flex flex-col items-center justify-start px-6 bg-transparent
|
|
711
|
+
<div className="relative min-h-[100svh] h-[100svh] w-[100svw] overflow-hidden flex flex-col items-center justify-start px-6 bg-transparent pt-[10svh] sm:pt-[33svh]">
|
|
533
712
|
{/* Aurora background */}
|
|
534
713
|
<div className="fixed inset-0 -z-10 pointer-events-none">
|
|
535
714
|
<Aurora amplitude={1.0} blend={0.6} speed={0.4} />
|
|
@@ -551,9 +730,112 @@ export default function Home(): JSX.Element {
|
|
|
551
730
|
QueryKit
|
|
552
731
|
</h1>
|
|
553
732
|
<p className="text-base sm:text-lg text-muted-foreground mt-2">
|
|
554
|
-
|
|
733
|
+
A type-safe search DSL for React apps. Filter the table and see the
|
|
734
|
+
SQL it generates.
|
|
555
735
|
</p>
|
|
556
736
|
</div>
|
|
737
|
+
<div className="flex justify-center">
|
|
738
|
+
<Drawer
|
|
739
|
+
direction="right"
|
|
740
|
+
open={isDrawerOpen}
|
|
741
|
+
onOpenChange={open => {
|
|
742
|
+
setIsDrawerOpen(open);
|
|
743
|
+
if (!open) {
|
|
744
|
+
setCopiedSnippet(null);
|
|
745
|
+
}
|
|
746
|
+
}}
|
|
747
|
+
>
|
|
748
|
+
<DrawerTrigger asChild>
|
|
749
|
+
<button
|
|
750
|
+
type="button"
|
|
751
|
+
className="inline-flex items-center gap-2 rounded-full bg-transparent px-5 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
752
|
+
aria-label="Open the QueryKit getting started guide"
|
|
753
|
+
>
|
|
754
|
+
<FileCode className="h-4 w-4" />
|
|
755
|
+
Getting started
|
|
756
|
+
</button>
|
|
757
|
+
</DrawerTrigger>
|
|
758
|
+
<DrawerContent className="data-[vaul-drawer-direction=right]:h-full data-[vaul-drawer-direction=right]:w-full data-[vaul-drawer-direction=right]:max-w-full data-[vaul-drawer-direction=right]:sm:max-w-xl data-[vaul-drawer-direction=right]:rounded-l-3xl">
|
|
759
|
+
<div className="flex h-full flex-col">
|
|
760
|
+
<DrawerHeader className="relative pb-2 text-left px-4 sm:px-6">
|
|
761
|
+
<DrawerTitle>Get started with QueryKit</DrawerTitle>
|
|
762
|
+
<DrawerDescription>
|
|
763
|
+
Install the packages, describe your data, and run your first
|
|
764
|
+
QueryKit search with the snippets below.
|
|
765
|
+
</DrawerDescription>
|
|
766
|
+
<DrawerClose asChild>
|
|
767
|
+
<button
|
|
768
|
+
type="button"
|
|
769
|
+
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:bg-accent sm:right-6"
|
|
770
|
+
aria-label="Close view code drawer"
|
|
771
|
+
>
|
|
772
|
+
<X className="h-4 w-4" />
|
|
773
|
+
</button>
|
|
774
|
+
</DrawerClose>
|
|
775
|
+
</DrawerHeader>
|
|
776
|
+
<div className="flex-1 space-y-5 overflow-y-auto px-4 pb-4 sm:px-6">
|
|
777
|
+
{quickStartSections.map(section => (
|
|
778
|
+
<div key={section.id} className="space-y-2">
|
|
779
|
+
<div className="space-y-1">
|
|
780
|
+
<div className="text-sm font-medium">
|
|
781
|
+
{section.title}
|
|
782
|
+
</div>
|
|
783
|
+
<p className="text-xs text-muted-foreground">
|
|
784
|
+
{section.description}
|
|
785
|
+
</p>
|
|
786
|
+
</div>
|
|
787
|
+
<div
|
|
788
|
+
role="region"
|
|
789
|
+
aria-label={`${section.title} code example`}
|
|
790
|
+
className="relative overflow-hidden rounded-md border bg-muted"
|
|
791
|
+
>
|
|
792
|
+
<button
|
|
793
|
+
type="button"
|
|
794
|
+
onClick={() =>
|
|
795
|
+
handleCopySnippet(section.id, section.code)
|
|
796
|
+
}
|
|
797
|
+
aria-label={`Copy ${section.title}`}
|
|
798
|
+
title={`Copy ${section.title}`}
|
|
799
|
+
className={cn(
|
|
800
|
+
'absolute right-2 inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent text-muted-foreground transition-colors hover:bg-muted/60 sm:right-3',
|
|
801
|
+
section.code.includes('\n')
|
|
802
|
+
? 'top-2 sm:top-3'
|
|
803
|
+
: 'top-1/2 -translate-y-1/2 sm:top-1/2 sm:-translate-y-1/2'
|
|
804
|
+
)}
|
|
805
|
+
>
|
|
806
|
+
<Copy
|
|
807
|
+
className={`h-4 w-4 transition-all duration-200 ${
|
|
808
|
+
copiedSnippet === section.id
|
|
809
|
+
? 'opacity-0 scale-90'
|
|
810
|
+
: 'opacity-100 scale-100'
|
|
811
|
+
}`}
|
|
812
|
+
/>
|
|
813
|
+
<Check
|
|
814
|
+
className={`absolute h-4 w-4 text-emerald-500 transition-all duration-200 ${
|
|
815
|
+
copiedSnippet === section.id
|
|
816
|
+
? 'opacity-100 scale-100'
|
|
817
|
+
: 'opacity-0 scale-110'
|
|
818
|
+
}`}
|
|
819
|
+
/>
|
|
820
|
+
</button>
|
|
821
|
+
<div className="h-full overflow-auto">
|
|
822
|
+
<SyntaxHighlighter
|
|
823
|
+
language={section.language}
|
|
824
|
+
style={atomOneDarkReasonable}
|
|
825
|
+
wrapLongLines
|
|
826
|
+
className="quick-start-snippet"
|
|
827
|
+
>
|
|
828
|
+
{section.code}
|
|
829
|
+
</SyntaxHighlighter>
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
))}
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
</DrawerContent>
|
|
837
|
+
</Drawer>
|
|
838
|
+
</div>
|
|
557
839
|
{/* Inline search input with recommendation popover */}
|
|
558
840
|
<div className="relative z-50 w-full">
|
|
559
841
|
<div className="flex items-center gap-2 rounded-2xl border bg-background shadow-sm px-3 py-2">
|
|
@@ -563,7 +845,7 @@ export default function Home(): JSX.Element {
|
|
|
563
845
|
{query && (
|
|
564
846
|
<div
|
|
565
847
|
aria-hidden
|
|
566
|
-
className="pointer-events-none absolute inset-0 z-0 whitespace-pre text-
|
|
848
|
+
className="pointer-events-none absolute inset-0 z-0 whitespace-pre text-base leading-[1.25rem] text-foreground/90 flex items-center"
|
|
567
849
|
dangerouslySetInnerHTML={{
|
|
568
850
|
__html: highlightQueryHtml(query)
|
|
569
851
|
}}
|
|
@@ -595,8 +877,9 @@ export default function Home(): JSX.Element {
|
|
|
595
877
|
}
|
|
596
878
|
}, 0);
|
|
597
879
|
}}
|
|
598
|
-
placeholder="Search
|
|
599
|
-
|
|
880
|
+
placeholder="Search QueryKit with key:value"
|
|
881
|
+
inputMode="search"
|
|
882
|
+
className={`placeholder:text-muted-foreground relative z-10 flex h-10 w-full rounded-md bg-transparent text-base leading-[1.25rem] outline-none ${query ? 'text-transparent caret-foreground' : ''}`}
|
|
600
883
|
/>
|
|
601
884
|
</div>
|
|
602
885
|
<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">
|
|
@@ -649,88 +932,105 @@ export default function Home(): JSX.Element {
|
|
|
649
932
|
<Card className="w-full">
|
|
650
933
|
{lastExecutedQuery && (
|
|
651
934
|
<CardContent>
|
|
652
|
-
<div
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
>
|
|
662
|
-
<
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
: '-'}
|
|
935
|
+
<div
|
|
936
|
+
ref={cardContentRef}
|
|
937
|
+
className="flex flex-col overflow-hidden"
|
|
938
|
+
style={{
|
|
939
|
+
maxHeight: cardMaxHeightPx
|
|
940
|
+
? `${cardMaxHeightPx}px`
|
|
941
|
+
: undefined
|
|
942
|
+
}}
|
|
943
|
+
>
|
|
944
|
+
<div className="relative bg-muted p-3 pr-12 rounded-md flex-1 min-h-0">
|
|
945
|
+
<button
|
|
946
|
+
type="button"
|
|
947
|
+
onClick={handleCopySql}
|
|
948
|
+
aria-label={'Copy SQL'}
|
|
949
|
+
title={'Copy SQL'}
|
|
950
|
+
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"
|
|
951
|
+
>
|
|
952
|
+
<Copy
|
|
953
|
+
className={`h-4 w-4 transition-all duration-200 ${hasCopiedSql ? 'opacity-0 scale-90' : 'opacity-100 scale-100'}`}
|
|
954
|
+
/>
|
|
955
|
+
<Check
|
|
956
|
+
className={`h-4 w-4 absolute transition-all duration-200 ${hasCopiedSql ? 'opacity-100 scale-100' : 'opacity-0 scale-110'}`}
|
|
957
|
+
/>
|
|
958
|
+
</button>
|
|
959
|
+
<div className="h-full overflow-auto">
|
|
960
|
+
<SyntaxHighlighter
|
|
961
|
+
language={'sql'}
|
|
962
|
+
style={atomOneDarkReasonable}
|
|
963
|
+
customStyle={{
|
|
964
|
+
background: 'transparent',
|
|
965
|
+
margin: 0,
|
|
966
|
+
whiteSpace: 'pre-wrap',
|
|
967
|
+
wordBreak: 'break-word',
|
|
968
|
+
overflowWrap: 'anywhere'
|
|
969
|
+
}}
|
|
970
|
+
wrapLongLines
|
|
971
|
+
>
|
|
972
|
+
{formattedSQL}
|
|
973
|
+
</SyntaxHighlighter>
|
|
698
974
|
</div>
|
|
699
975
|
</div>
|
|
700
|
-
<div className="
|
|
701
|
-
|
|
702
|
-
|
|
976
|
+
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
|
977
|
+
{!isShortViewport && (
|
|
978
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
979
|
+
<div className="text-xs text-muted-foreground">
|
|
980
|
+
DB time (EXPLAIN)
|
|
981
|
+
</div>
|
|
982
|
+
<div className="mt-1 text-base font-medium">
|
|
983
|
+
{dbExecutionMs !== null
|
|
984
|
+
? `${dbExecutionMs.toFixed(3)} ms`
|
|
985
|
+
: '-'}
|
|
986
|
+
</div>
|
|
987
|
+
</div>
|
|
988
|
+
)}
|
|
989
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
990
|
+
<div className="text-xs text-muted-foreground">
|
|
991
|
+
Rows returned
|
|
992
|
+
</div>
|
|
993
|
+
<div className="mt-1 text-base font-medium">{`${results.length} of ${rowsScanned ?? results.length}`}</div>
|
|
703
994
|
</div>
|
|
704
|
-
|
|
995
|
+
{!isShortViewport && (
|
|
996
|
+
<div className="rounded-md border p-3 bg-background/50">
|
|
997
|
+
<div className="text-xs text-muted-foreground">
|
|
998
|
+
Engine
|
|
999
|
+
</div>
|
|
1000
|
+
<div className="mt-1">
|
|
1001
|
+
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-muted">
|
|
1002
|
+
{usedQueryKit
|
|
1003
|
+
? 'QueryKit · Drizzle'
|
|
1004
|
+
: 'Client fallback'}
|
|
1005
|
+
</span>
|
|
1006
|
+
</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
705
1009
|
</div>
|
|
706
|
-
|
|
707
|
-
<div className="
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
))}
|
|
1010
|
+
{!isShortViewport ? (
|
|
1011
|
+
<div className="mt-3">
|
|
1012
|
+
<div className="text-xs text-muted-foreground mb-1">
|
|
1013
|
+
Detected operators
|
|
1014
|
+
</div>
|
|
1015
|
+
{operatorsUsed.length ? (
|
|
1016
|
+
<div className="flex flex-wrap gap-2">
|
|
1017
|
+
{operatorsUsed.map(op => (
|
|
1018
|
+
<span
|
|
1019
|
+
key={op}
|
|
1020
|
+
className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium"
|
|
1021
|
+
>
|
|
1022
|
+
{op}
|
|
1023
|
+
</span>
|
|
1024
|
+
))}
|
|
1025
|
+
</div>
|
|
1026
|
+
) : (
|
|
1027
|
+
<div className="text-xs text-muted-foreground">-</div>
|
|
1028
|
+
)}
|
|
731
1029
|
</div>
|
|
732
1030
|
) : (
|
|
733
|
-
<div className="text-xs text-muted-foreground"
|
|
1031
|
+
<div className="mt-3 text-xs text-muted-foreground">
|
|
1032
|
+
View on larger screen for more details
|
|
1033
|
+
</div>
|
|
734
1034
|
)}
|
|
735
1035
|
</div>
|
|
736
1036
|
{/* EXPLAIN has been integrated into the SQL window via the SearchCode toggle */}
|
|
@@ -741,19 +1041,21 @@ export default function Home(): JSX.Element {
|
|
|
741
1041
|
</div>
|
|
742
1042
|
|
|
743
1043
|
{/* Toggle button to open results drawer */}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1044
|
+
{!isDrawerOpen && (
|
|
1045
|
+
<button
|
|
1046
|
+
type="button"
|
|
1047
|
+
onClick={() => setIsResultsOpen(v => !v)}
|
|
1048
|
+
aria-expanded={isResultsOpen}
|
|
1049
|
+
aria-controls="results-drawer"
|
|
1050
|
+
title={isResultsOpen ? 'Hide results' : 'Show results'}
|
|
1051
|
+
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"
|
|
1052
|
+
>
|
|
1053
|
+
<ChevronUp
|
|
1054
|
+
className={`h-4 w-4 transition-transform ${isResultsOpen ? 'rotate-180' : ''}`}
|
|
1055
|
+
/>
|
|
1056
|
+
<span className="ml-2">{isResultsOpen ? 'Hide' : 'Results'}</span>
|
|
1057
|
+
</button>
|
|
1058
|
+
)}
|
|
757
1059
|
|
|
758
1060
|
{/* Bottom drawer with results table */}
|
|
759
1061
|
{isResultsOpen && (
|