@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.
@@ -21,14 +21,27 @@ import {
21
21
  createQueryKit,
22
22
  IDrizzleAdapterOptions
23
23
  } from '@gblikas/querykit';
24
- import { Copy, Check, Search, SearchCode, ChevronUp } from 'lucide-react';
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 jsonLanguage from 'react-syntax-highlighter/dist/esm/languages/hljs/json';
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 [explainJson, setExplainJson] = useState<string | null>(null);
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 [explainError, setExplainError] = useState<string | null>(null);
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 [showExplain, setShowExplain] = useState(false);
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
- SyntaxHighlighter as unknown as typeof SyntaxHighlighter
106
- ).registerLanguage('sql', sqlLanguage);
107
- (
108
- SyntaxHighlighter as unknown as typeof SyntaxHighlighter
109
- ).registerLanguage('json', jsonLanguage);
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
- setBaselineFetchMs(performance.now() - baselineStart);
261
- setRowsScanned(allTasks.length);
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
- setParseTranslateMs(performance.now() - parseStart);
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
- setOperatorsUsed(Array.from(new Set(detectedOperators)));
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
- setExplainLatencyMs(performance.now() - explainStart);
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') setDbExecutionMs(execution);
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 = showExplain
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(showExplain ? 'Failed to copy EXPLAIN' : 'Failed to copy SQL');
643
+ toast('Failed to copy SQL');
500
644
  }
501
- }, [formattedSQL, generatedSQL, explainJson, showExplain]);
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 overflow-hidden pt-[10svh] sm:pt-[33svh]">
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
- Try filtering tasks with the QueryKit DSL. Press ⌘K to start.
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-sm leading-[1.25rem] text-foreground/90 flex items-center"
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 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' : ''}`}
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 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
- : '-'}
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="rounded-md border p-3 bg-background/50">
701
- <div className="text-xs text-muted-foreground">
702
- Rows returned
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
- <div className="mt-1 text-base font-medium">{`${results.length} of ${rowsScanned ?? '-'}`}</div>
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
- <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
- ))}
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">-</div>
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
- <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>
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 && (