Enhancement/hive lab lib (#7697)

This commit is contained in:
Michael Skorokhodov 2026-03-06 22:37:08 +01:00 committed by GitHub
parent 5cb956ca23
commit 1bf05f048f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 6688 additions and 2016 deletions

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/laboratory': major
---
First release

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/render-laboratory': major
---
Render laboratory util for yoga

View file

@ -109,6 +109,8 @@ module.exports = {
'packages/migrations/**', 'packages/migrations/**',
// We bundle it all anyway, so there are no node_modules // We bundle it all anyway, so there are no node_modules
'packages/web/app/**', 'packages/web/app/**',
// We bundle it all anyway, so there are no node_modules
'packages/libraries/laboratory/**',
'**/*.spec.ts', '**/*.spec.ts',
'**/*.test.ts', '**/*.test.ts',
'**/*.e2e.ts', '**/*.e2e.ts',

View file

@ -54,6 +54,12 @@ runs:
working-directory: ${{ inputs.workingDirectory }} working-directory: ${{ inputs.workingDirectory }}
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: build laboratory
shell: bash
if: ${{ inputs.buildLaboratory == 'true' }}
working-directory: ${{ inputs.workingDirectory }}
run: pnpm turbo build --filter=./packages/libraries/laboratory --color
- name: generate graphql types - name: generate graphql types
if: ${{ inputs.codegen == 'true' }} if: ${{ inputs.codegen == 'true' }}
shell: bash shell: bash

View file

@ -15,6 +15,7 @@ jobs:
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
actor: typescript-typecheck actor: typescript-typecheck
buildLaboratory: true
- name: get cpu count - name: get cpu count
id: cpu-cores id: cpu-cores

View file

@ -287,12 +287,7 @@ describe('Execution', () => {
parseSpecialCharSequences: false, parseSpecialCharSequences: false,
}, },
); );
cy.dataCy('env-editor-mini').within(() => { setMonacoEditorContents('env-editor-mini', '{"foo":"injected"}');
cy.get('textarea').type('{"foo":"injected"}', {
force: true,
parseSpecialCharSequences: false,
});
});
cy.intercept({ cy.intercept({
method: 'POST', method: 'POST',

View file

@ -142,6 +142,7 @@
"ip": "npm:neoip@2.1.0", "ip": "npm:neoip@2.1.0",
"miniflare@3>undici": "^7.18.2", "miniflare@3>undici": "^7.18.2",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"@graphql-hive/laboratory>tailwindcss": "4.1.18",
"@hive/app>tailwindcss": "4.1.18", "@hive/app>tailwindcss": "4.1.18",
"@tailwindcss/node>tailwindcss": "4.1.18", "@tailwindcss/node>tailwindcss": "4.1.18",
"@tailwindcss/vite>tailwindcss": "4.1.18", "@tailwindcss/vite>tailwindcss": "4.1.18",
@ -155,6 +156,7 @@
"seroval@<1.4.1": "^1.4.1", "seroval@<1.4.1": "^1.4.1",
"fast-xml-parser@<5.3.8": "^5.3.8", "fast-xml-parser@<5.3.8": "^5.3.8",
"minimatch@10.x.x": "^10.2.2", "minimatch@10.x.x": "^10.2.2",
"amqplib": "^0.8.0",
"minimatch@9.x.x": "^9.0.6", "minimatch@9.x.x": "^9.0.6",
"minimatch@3.x.x": "^3.1.3", "minimatch@3.x.x": "^3.1.3",
"minimatch@4.x.x": "^4.2.4", "minimatch@4.x.x": "^4.2.4",
@ -179,7 +181,8 @@
"bentocache": "patches/bentocache.patch", "bentocache": "patches/bentocache.patch",
"nextra": "patches/nextra.patch", "nextra": "patches/nextra.patch",
"nextra-theme-docs": "patches/nextra-theme-docs.patch", "nextra-theme-docs": "patches/nextra-theme-docs.patch",
"@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch" "@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch",
"@fastify/vite": "patches/@fastify__vite.patch"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"msw" "msw"

View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/laboratory/components",
"utils": "@/laboratory/lib/utils",
"ui": "@/laboratory/components/ui",
"lib": "@/laboratory/lib",
"hooks": "@/laboratory/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="h-full w-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hive Laboratory</title>
<link href="/src/index.css" rel="stylesheet" />
<!-- <script src="https://cdn.jsdelivr.net/npm/react-scan/dist/auto.global.js"></script> -->
</head>
<body class="dark h-full w-full">
<div id="root" class="h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,145 @@
{
"name": "@graphql-hive/laboratory",
"version": "0.0.1",
"type": "module",
"main": "./dist/hive-laboratory.cjs.js",
"module": "./dist/hive-laboratory.es.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/hive-laboratory.es.js",
"require": "./dist/hive-laboratory.cjs.js"
},
"./umd": "./dist/hive-laboratory.umd.js"
},
"jsdelivr": "./dist/hive-laboratory.umd.js",
"unpkg": "./dist/hive-laboratory.umd.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "vite build --config vite.lib.config.ts && vite build --config vite.umd.config.ts",
"dev": "vite",
"dev:electron": "VITE_TARGET=electron concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"lint": "eslint .",
"preview": "vite preview"
},
"peerDependencies": {
"@tanstack/react-form": "^1.23.8",
"date-fns": "^4.1.0",
"graphql-ws": "^6.0.6",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"tslib": "^2.8.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@dagrejs/dagre": "^1.1.8",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@eslint/js": "^9.39.2",
"@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "4.8.0-rc.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-router": "^1.154.13",
"@tanstack/react-router-devtools": "^1.154.13",
"@tanstack/router-plugin": "^1.154.13",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.17.23",
"@types/node": "^24.10.9",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "^5.1.2",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.23",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"concurrently": "^9.2.1",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"esbuild": "^0.25.12",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.6",
"lodash": "^4.17.23",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.2",
"monaco-graphql": "^1.7.3",
"monacopilot": "^1.2.12",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"postcss-prefixwrap": "^1.57.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^3.0.6",
"react-shadow": "^20.6.0",
"rollup-plugin-typescript2": "^0.36.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-scoped-preflight": "^3.5.7",
"tslib": "^2.8.1",
"tsup": "^8.5.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.1",
"unplugin-dts": "1.0.0-beta.6",
"vite": "npm:rolldown-vite@7.1.14",
"vite-plugin-commonjs": "^0.10.4",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-monaco-editor": "^1.1.0",
"wait-on": "^9.0.3",
"zod": "^4.3.6"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"sideEffects": [
"**/*.css"
],
"build": {
"appId": "com.guild.hive.laboratory",
"productName": "Hive Laboratory",
"artifactName": "HiveLab-${version}-Do_Not_Open_(Seriously_It's_Pre-Release)-${os}-${arch}.${ext}",
"files": [
"dist/**/*",
"electron/**/*"
],
"directories": {
"buildResources": "assets"
}
}
}

View file

@ -15,7 +15,7 @@ export const GraphQLType = (props: {
return ( return (
<span> <span>
<GraphQLType type={props.type.ofType} /> <GraphQLType type={props.type.ofType} />
<span className="text-neutral-10!">!</span> <span className="text-muted-foreground!">!</span>
</span> </span>
); );
} }
@ -23,9 +23,9 @@ export const GraphQLType = (props: {
if (props.type instanceof GraphQLList) { if (props.type instanceof GraphQLList) {
return ( return (
<span> <span>
<span className="text-neutral-10!">[</span> <span className="text-muted-foreground!">[</span>
<GraphQLType type={props.type.ofType} /> <GraphQLType type={props.type.ofType} />
<span className="text-neutral-10!">]</span> <span className="text-muted-foreground!">]</span>
</span> </span>
); );
} }

View file

@ -1,3 +1,4 @@
import type { SVGProps } from 'react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
export const GraphQLIcon = (props: LucideProps) => { export const GraphQLIcon = (props: LucideProps) => {
@ -8,7 +9,7 @@ export const GraphQLIcon = (props: LucideProps) => {
viewBox="0 0 100 100" viewBox="0 0 100 100"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...(props as SVGProps<SVGSVGElement>)}
> >
<path <path
fillRule="evenodd" fillRule="evenodd"

View file

@ -16,35 +16,20 @@ import {
FolderIcon, FolderIcon,
RotateCcwIcon, RotateCcwIcon,
} from 'lucide-react'; } from 'lucide-react';
import { GraphQLType } from '@/laboratory/components/graphql-type'; import type { LaboratoryOperation } from '../../lib/operations';
import { GraphQLIcon } from '@/laboratory/components/icons'; import { getOpenPaths, isArgInQuery, isPathInQuery } from '../../lib/operations.utils';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { cn } from '../../lib/utils';
import { Button } from '@/laboratory/components/ui/button'; import { GraphQLType } from '../graphql-type';
import { Checkbox } from '@/laboratory/components/ui/checkbox'; import { GraphQLIcon } from '../icons';
import { import { Button } from '../ui/button';
Collapsible, import { Checkbox } from '../ui/checkbox';
CollapsibleContent, import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
CollapsibleTrigger, import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
} from '@/laboratory/components/ui/collapsible'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
import { import { ScrollArea, ScrollBar } from '../ui/scroll-area';
Empty, import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
EmptyDescription, import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
EmptyHeader, import { useLaboratory } from './context';
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/laboratory/components/ui/input-group';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/laboratory/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import { getOpenPaths, isArgInQuery, isPathInQuery } from '@/laboratory/lib/operations.utils';
import { cn } from '@/laboratory/lib/utils';
export const BuilderArgument = (props: { export const BuilderArgument = (props: {
field: GraphQLArgument; field: GraphQLArgument;
@ -76,8 +61,8 @@ export const BuilderArgument = (props: {
<Button <Button
key={props.field.name} key={props.field.name}
variant="ghost" variant="ghost"
className={cn('text-neutral-10 p-1! w-full justify-start text-xs', { className={cn('text-muted-foreground p-1! w-full justify-start text-xs', {
'text-neutral-11': isInQuery, 'text-foreground-primary': isInQuery,
})} })}
size="sm" size="sm"
> >
@ -157,9 +142,9 @@ export const BuilderScalarField = (props: {
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
'text-neutral-10 bg-neutral-2 p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs', 'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
{ {
'text-neutral-11': isInQuery, 'text-foreground-primary': isInQuery,
}, },
)} )}
style={{ style={{
@ -167,10 +152,10 @@ export const BuilderScalarField = (props: {
}} }}
size="sm" size="sm"
> >
<div className="bg-neutral-2 absolute left-0 top-0 -z-20 size-full" /> <div className="bg-card absolute left-0 top-0 -z-20 size-full" />
<div className="group-hover:bg-neutral-2 absolute left-0 top-0 -z-10 size-full transition-colors" /> <div className="group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
<ChevronDownIcon <ChevronDownIcon
className={cn('text-neutral-10 size-4 transition-all', { className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen, '-rotate-90': !isOpen,
})} })}
/> />
@ -191,7 +176,7 @@ export const BuilderScalarField = (props: {
{props.field.name}: <GraphQLType type={props.field.type} /> {props.field.name}: <GraphQLType type={props.field.type} />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="border-neutral-5 relative z-0 ml-3 flex flex-col border-l pl-2"> <CollapsibleContent className="border-border relative z-0 ml-3 flex flex-col border-l pl-2">
{isOpen && ( {isOpen && (
<div> <div>
{args.length > 0 && ( {args.length > 0 && (
@ -200,9 +185,9 @@ export const BuilderScalarField = (props: {
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
'text-neutral-10 bg-neutral-2 p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs', 'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
{ {
'text-neutral-11': hasArgs, 'text-foreground-primary': hasArgs,
}, },
)} )}
style={{ style={{
@ -211,7 +196,7 @@ export const BuilderScalarField = (props: {
size="sm" size="sm"
> >
<ChevronDownIcon <ChevronDownIcon
className={cn('text-neutral-10 size-4 transition-all', { className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen, '-rotate-90': !isOpen,
})} })}
/> />
@ -220,7 +205,7 @@ export const BuilderScalarField = (props: {
[arguments] [arguments]
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="border-neutral-5 ml-3 flex flex-col border-l pl-2"> <CollapsibleContent className="border-border ml-3 flex flex-col border-l pl-2">
{args.map(arg => ( {args.map(arg => (
<BuilderArgument <BuilderArgument
key={arg.name} key={arg.name}
@ -244,8 +229,8 @@ export const BuilderScalarField = (props: {
<Button <Button
key={props.field.name} key={props.field.name}
variant="ghost" variant="ghost"
className={cn('text-neutral-10 p-1! w-full justify-start text-xs', { className={cn('text-muted-foreground p-1! w-full justify-start text-xs', {
'text-neutral-11': isInQuery, 'text-foreground-primary': isInQuery,
})} })}
size="sm" size="sm"
> >
@ -335,9 +320,9 @@ export const BuilderObjectField = (props: {
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
'text-neutral-10 bg-neutral-2 p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs', 'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
{ {
'text-neutral-11': isInQuery, 'text-foreground-primary': isInQuery,
}, },
)} )}
style={{ style={{
@ -345,10 +330,10 @@ export const BuilderObjectField = (props: {
}} }}
size="sm" size="sm"
> >
<div className="bg-neutral-2 absolute left-0 top-0 -z-20 size-full" /> <div className="bg-card absolute left-0 top-0 -z-20 size-full" />
<div className="group-hover:bg-neutral-2 absolute left-0 top-0 -z-10 size-full transition-colors" /> <div className="group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
<ChevronDownIcon <ChevronDownIcon
className={cn('text-neutral-10 size-4 transition-all', { className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen, '-rotate-90': !isOpen,
})} })}
/> />
@ -369,7 +354,7 @@ export const BuilderObjectField = (props: {
{props.field.name}: <GraphQLType type={props.field.type} /> {props.field.name}: <GraphQLType type={props.field.type} />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="border-neutral-5 relative z-0 ml-4 flex flex-col border-l pl-1"> <CollapsibleContent className="border-border relative z-0 ml-4 flex flex-col border-l pl-1">
{isOpen && ( {isOpen && (
<div> <div>
{args.length > 0 && ( {args.length > 0 && (
@ -378,9 +363,9 @@ export const BuilderObjectField = (props: {
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
'text-neutral-10 bg-neutral-2 p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs', 'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
{ {
'text-neutral-11': hasArgs, 'text-foreground-primary': hasArgs,
}, },
)} )}
style={{ style={{
@ -389,7 +374,7 @@ export const BuilderObjectField = (props: {
size="sm" size="sm"
> >
<ChevronDownIcon <ChevronDownIcon
className={cn('text-neutral-10 size-4 transition-all', { className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen, '-rotate-90': !isOpen,
})} })}
/> />
@ -398,7 +383,7 @@ export const BuilderObjectField = (props: {
[arguments] [arguments]
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="border-neutral-5 ml-4 flex flex-col border-l pl-1"> <CollapsibleContent className="border-border ml-4 flex flex-col border-l pl-1">
{args.map(arg => ( {args.map(arg => (
<BuilderArgument <BuilderArgument
key={arg.name} key={arg.name}
@ -529,7 +514,7 @@ export const Builder = (props: {
}, [defaultEndpoint, setEndpointValue]); }, [defaultEndpoint, setEndpointValue]);
return ( return (
<div className="bg-neutral-2 flex size-full flex-col overflow-hidden"> <div className="bg-card flex size-full flex-col overflow-hidden">
<div className="flex items-center px-3 pt-3"> <div className="flex items-center px-3 pt-3">
<span className="text-base font-medium">Builder</span> <span className="text-base font-medium">Builder</span>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
@ -542,7 +527,7 @@ export const Builder = (props: {
className="p-1! size-6 rounded-sm" className="p-1! size-6 rounded-sm"
disabled={openPaths.length === 0} disabled={openPaths.length === 0}
> >
<CopyMinusIcon className="text-neutral-10 size-4" /> <CopyMinusIcon className="text-muted-foreground size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent> <TooltipContent>Collapse all</TooltipContent>
@ -581,7 +566,7 @@ export const Builder = (props: {
onValueChange={setTabValue} onValueChange={setTabValue}
className="flex size-full flex-col gap-0" className="flex size-full flex-col gap-0"
> >
<div className="border-neutral-5 flex items-center border-b p-3"> <div className="border-border flex items-center border-b p-3">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="query" disabled={queryFields.length === 0} className="text-xs"> <TabsTrigger value="query" disabled={queryFields.length === 0} className="text-xs">
Query Query
@ -654,7 +639,7 @@ export const Builder = (props: {
<Empty className="px-0! h-96 w-full"> <Empty className="px-0! h-96 w-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<FolderIcon className="text-neutral-10 size-6" /> <FolderIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle className="text-base">No endpoint selected</EmptyTitle> <EmptyTitle className="text-base">No endpoint selected</EmptyTitle>
<EmptyDescription className="text-xs"> <EmptyDescription className="text-xs">

View file

@ -7,8 +7,10 @@ import {
TrashIcon, TrashIcon,
XIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
import { GraphQLIcon } from '@/laboratory/components/icons'; import { TooltipTrigger } from '@radix-ui/react-tooltip';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
import { cn } from '../../lib/utils';
import { GraphQLIcon } from '../icons';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -19,13 +21,9 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/laboratory/components/ui/alert-dialog'; } from '../ui/alert-dialog';
import { Button } from '@/laboratory/components/ui/button'; import { Button } from '../ui/button';
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/laboratory/components/ui/collapsible';
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@ -33,16 +31,11 @@ import {
EmptyHeader, EmptyHeader,
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '@/laboratory/components/ui/empty'; } from '../ui/empty';
import { Input } from '@/laboratory/components/ui/input'; import { Input } from '../ui/input';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area'; import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Tooltip, TooltipContent } from '@/laboratory/components/ui/tooltip'; import { Tooltip, TooltipContent } from '../ui/tooltip';
import type { import { useLaboratory } from './context';
LaboratoryCollection,
LaboratoryCollectionOperation,
} from '@/laboratory/lib/collections';
import { cn } from '@/laboratory/lib/utils';
import { TooltipTrigger } from '@radix-ui/react-tooltip';
export const CollectionItem = (props: { collection: LaboratoryCollection }) => { export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
const { const {
@ -64,13 +57,13 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="bg-neutral-3 group sticky top-0 w-full justify-start px-2" className="bg-background group sticky top-0 w-full justify-start px-2"
size="sm" size="sm"
> >
{isOpen ? ( {isOpen ? (
<FolderOpenIcon className="text-neutral-10 size-4" /> <FolderOpenIcon className="text-muted-foreground size-4" />
) : ( ) : (
<FolderIcon className="text-neutral-10 size-4" /> <FolderIcon className="text-muted-foreground size-4" />
)} )}
{props.collection.name} {props.collection.name}
{checkPermissions?.('collections:delete') && ( {checkPermissions?.('collections:delete') && (
@ -80,7 +73,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="link" variant="link"
className="text-neutral-10 p-1! pr-0! ml-auto opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100" className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -120,7 +113,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
)} )}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className={cn('border-neutral-5 ml-4 flex flex-col gap-1 border-l pl-2')}> <CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
{isOpen && {isOpen &&
props.collection.operations.map(operation => { props.collection.operations.map(operation => {
const isActive = activeOperation?.id === operation.id; const isActive = activeOperation?.id === operation.id;
@ -130,7 +123,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
key={operation.name} key={operation.name}
variant="ghost" variant="ghost"
className={cn('group w-full justify-start gap-2 px-2', { className={cn('group w-full justify-start gap-2 px-2', {
'bg-accent_80': isActive, 'bg-accent/50': isActive,
})} })}
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -156,7 +149,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="link" variant="link"
className="text-neutral-10 p-1! pr-0! ml-auto opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100" className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -219,7 +212,7 @@ export const CollectionsSearchResult = (props: { items: CollectionsSearchResultI
key={operation.name} key={operation.name}
variant="ghost" variant="ghost"
className={cn('group w-full justify-start gap-2 px-2', { className={cn('group w-full justify-start gap-2 px-2', {
'bg-accent_80': isActive, 'bg-accent/50': isActive,
})} })}
size="sm" size="sm"
onClick={() => { onClick={() => {
@ -237,8 +230,8 @@ export const CollectionsSearchResult = (props: { items: CollectionsSearchResultI
}} }}
> >
<GraphQLIcon className="size-4 text-pink-500" /> <GraphQLIcon className="size-4 text-pink-500" />
<span className="text-neutral-10 truncate">{operation.parent.name}</span> <span className="text-muted-foreground truncate">{operation.parent.name}</span>
<span className="text-neutral-10">{' / '}</span> <span className="text-muted-foreground">{' / '}</span>
{operation.name} {operation.name}
</Button> </Button>
); );
@ -282,7 +275,7 @@ export const Collections = () => {
className="p-1! size-6 rounded-sm" className="p-1! size-6 rounded-sm"
onClick={openAddCollectionDialog} onClick={openAddCollectionDialog}
> >
<FolderPlusIcon className="text-neutral-11 size-4" /> <FolderPlusIcon className="text-primary size-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Add collection</TooltipContent> <TooltipContent>Add collection</TooltipContent>
@ -290,8 +283,8 @@ export const Collections = () => {
)} )}
</div> </div>
</div> </div>
<div className="border-neutral-5 relative border-b p-3"> <div className="border-border relative border-b p-3">
<SearchIcon className="text-neutral-10 absolute left-5 top-1/2 size-4 -translate-y-1/2" /> <SearchIcon className="text-muted-foreground absolute left-5 top-1/2 size-4 -translate-y-1/2" />
<Input <Input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
@ -306,7 +299,7 @@ export const Collections = () => {
className="p-1! absolute right-5 top-1/2 size-6 -translate-y-1/2 rounded-sm" className="p-1! absolute right-5 top-1/2 size-6 -translate-y-1/2 rounded-sm"
onClick={() => setSearch('')} onClick={() => setSearch('')}
> >
<XIcon className="text-neutral-10 size-4" /> <XIcon className="text-muted-foreground size-4" />
</Button> </Button>
)} )}
</div> </div>
@ -321,7 +314,7 @@ export const Collections = () => {
<Empty className="px-0! w-full"> <Empty className="px-0! w-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<SearchIcon className="text-neutral-10 size-6" /> <SearchIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle className="text-base">No results found</EmptyTitle> <EmptyTitle className="text-base">No results found</EmptyTitle>
<EmptyDescription className="text-xs"> <EmptyDescription className="text-xs">
@ -336,7 +329,7 @@ export const Collections = () => {
<Empty className="px-0! w-full"> <Empty className="px-0! w-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<FolderIcon className="text-neutral-10 size-6" /> <FolderIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle className="text-base">No collections yet</EmptyTitle> <EmptyTitle className="text-base">No collections yet</EmptyTitle>
<EmptyDescription className="text-xs"> <EmptyDescription className="text-xs">

View file

@ -1,6 +1,5 @@
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { FilePlus2Icon, FolderPlusIcon, PlayIcon, RefreshCcwIcon, ServerIcon } from 'lucide-react'; import { FilePlus2Icon, FolderPlusIcon, PlayIcon, RefreshCcwIcon, ServerIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { import {
CommandDialog, CommandDialog,
CommandEmpty, CommandEmpty,
@ -10,7 +9,8 @@ import {
CommandList, CommandList,
CommandSeparator, CommandSeparator,
CommandShortcut, CommandShortcut,
} from '@/laboratory/components/ui/command'; } from '../ui/command';
import { useLaboratory } from './context';
export function Command(props: { open?: boolean; onOpenChange?: (open: boolean) => void }) { export function Command(props: { open?: boolean; onOpenChange?: (open: boolean) => void }) {
const { const {

View file

@ -5,47 +5,36 @@ import {
type LaboratoryCollectionOperation, type LaboratoryCollectionOperation,
type LaboratoryCollectionsActions, type LaboratoryCollectionsActions,
type LaboratoryCollectionsState, type LaboratoryCollectionsState,
} from '@/laboratory/lib/collections'; } from '../../lib/collections';
import { import { type LaboratoryEndpointActions, type LaboratoryEndpointState } from '../../lib/endpoint';
type LaboratoryEndpointActions, import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '../../lib/env';
type LaboratoryEndpointState,
} from '@/laboratory/lib/endpoint';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env';
import type { import type {
LaboratoryHistory, LaboratoryHistory,
LaboratoryHistoryActions, LaboratoryHistoryActions,
LaboratoryHistoryState, LaboratoryHistoryState,
} from '@/laboratory/lib/history'; } from '../../lib/history';
import { import {
type LaboratoryOperation, type LaboratoryOperation,
type LaboratoryOperationsActions, type LaboratoryOperationsActions,
type LaboratoryOperationsState, type LaboratoryOperationsState,
} from '@/laboratory/lib/operations'; } from '../../lib/operations';
import { import {
LaboratoryPlugin, LaboratoryPlugin,
LaboratoryPluginsActions, LaboratoryPluginsActions,
LaboratoryPluginsState, LaboratoryPluginsState,
} from '@/laboratory/lib/plugins'; } from '../../lib/plugins';
import type { import type {
LaboratoryPreflight, LaboratoryPreflight,
LaboratoryPreflightActions, LaboratoryPreflightActions,
LaboratoryPreflightState, LaboratoryPreflightState,
} from '@/laboratory/lib/preflight'; } from '../../lib/preflight';
import type { import type {
LaboratorySettings, LaboratorySettings,
LaboratorySettingsActions, LaboratorySettingsActions,
LaboratorySettingsState, LaboratorySettingsState,
} from '@/laboratory/lib/settings'; } from '../../lib/settings';
import type { import type { LaboratoryTab, LaboratoryTabsActions, LaboratoryTabsState } from '../../lib/tabs';
LaboratoryTab, import type { LaboratoryTest, LaboratoryTestActions, LaboratoryTestState } from '../../lib/tests';
LaboratoryTabsActions,
LaboratoryTabsState,
} from '@/laboratory/lib/tabs';
import type {
LaboratoryTest,
LaboratoryTestActions,
LaboratoryTestState,
} from '@/laboratory/lib/tests';
type LaboratoryContextState = LaboratoryCollectionsState & type LaboratoryContextState = LaboratoryCollectionsState &
LaboratoryEndpointState & LaboratoryEndpointState &
@ -58,6 +47,7 @@ type LaboratoryContextState = LaboratoryCollectionsState &
LaboratoryPluginsState & LaboratoryPluginsState &
LaboratoryTestState & { LaboratoryTestState & {
isFullScreen?: boolean; isFullScreen?: boolean;
theme?: 'light' | 'dark';
}; };
type LaboratoryContextActions = LaboratoryCollectionsActions & type LaboratoryContextActions = LaboratoryCollectionsActions &
LaboratoryEndpointActions & LaboratoryEndpointActions &
@ -73,9 +63,7 @@ type LaboratoryContextActions = LaboratoryCollectionsActions &
openUpdateEndpointDialog?: () => void; openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void; openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: { openPreflightPromptModal?: (props: {
title?: string; placeholder: string;
description?: string;
placeholder?: string;
defaultValue?: string; defaultValue?: string;
onSubmit?: (value: string | null) => void; onSubmit?: (value: string | null) => void;
}) => void; }) => void;
@ -86,9 +74,12 @@ type LaboratoryContextActions = LaboratoryCollectionsActions &
) => boolean; ) => boolean;
}; };
const LaboratoryContext = createContext<LaboratoryContextState & LaboratoryContextActions>( type LaboratoryContext = LaboratoryContextState &
{} as LaboratoryContextState & LaboratoryContextActions, LaboratoryContextActions & {
); container: HTMLDivElement | null;
};
const LaboratoryContext = createContext<LaboratoryContext>({} as LaboratoryContext);
export const useLaboratory = () => { export const useLaboratory = () => {
return useContext(LaboratoryContext); return useContext(LaboratoryContext);
@ -108,6 +99,7 @@ export interface LaboratoryPermissions {
} }
export interface LaboratoryApi { export interface LaboratoryApi {
theme?: 'light' | 'dark';
defaultEndpoint?: string | null; defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void; onEndpointChange?: (endpoint: string | null) => void;
defaultSchemaIntrospection?: IntrospectionQuery | null; defaultSchemaIntrospection?: IntrospectionQuery | null;
@ -144,9 +136,7 @@ export interface LaboratoryApi {
openUpdateEndpointDialog?: () => void; openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void; openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: { openPreflightPromptModal?: (props: {
title?: string; placeholder: string;
description?: string;
placeholder?: string;
defaultValue?: string; defaultValue?: string;
onSubmit?: (value: string | null) => void; onSubmit?: (value: string | null) => void;
}) => void; }) => void;
@ -176,7 +166,9 @@ export interface LaboratoryApi {
export type LaboratoryContextProps = LaboratoryContextState & export type LaboratoryContextProps = LaboratoryContextState &
LaboratoryContextActions & LaboratoryContextActions &
LaboratoryApi; LaboratoryApi & {
container: HTMLDivElement | null;
};
export const LaboratoryProvider = (props: React.PropsWithChildren<LaboratoryContextProps>) => { export const LaboratoryProvider = (props: React.PropsWithChildren<LaboratoryContextProps>) => {
return ( return (

View file

@ -1,11 +1,11 @@
import { forwardRef, useEffect, useId, useImperativeHandle, useRef } from 'react'; import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import { initializeMode } from 'monaco-graphql/initializeMode'; import { initializeMode } from 'monaco-graphql/initializeMode';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import MonacoEditor, { loader } from '@monaco-editor/react'; import MonacoEditor, { loader } from '@monaco-editor/react';
import { useLaboratory } from './context';
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
(window as any).monaco = monaco; (window as Window & typeof globalThis & { monaco: typeof monaco }).monaco = monaco;
} }
loader.config({ monaco }); loader.config({ monaco });
@ -73,7 +73,7 @@ const darkTheme: monaco.editor.IStandaloneThemeData = {
], ],
colors: { colors: {
'editor.foreground': '#f6f8fa', 'editor.foreground': '#f6f8fa',
'editor.background': '#18181b', 'editor.background': '#0f1214',
'editor.selectionBackground': '#2A2F34', 'editor.selectionBackground': '#2A2F34',
'editor.inactiveSelectionBackground': '#2A2F34', 'editor.inactiveSelectionBackground': '#2A2F34',
'editor.lineHighlightBackground': '#2A2F34', 'editor.lineHighlightBackground': '#2A2F34',
@ -87,6 +87,15 @@ const darkTheme: monaco.editor.IStandaloneThemeData = {
monaco.editor.defineTheme('hive-laboratory-dark', darkTheme); monaco.editor.defineTheme('hive-laboratory-dark', darkTheme);
const lightTheme: monaco.editor.IStandaloneThemeData = {
base: 'vs',
inherit: true,
rules: [],
colors: {},
};
monaco.editor.defineTheme('hive-laboratory-light', lightTheme);
monaco.languages.setMonarchTokensProvider('dotenv', { monaco.languages.setMonarchTokensProvider('dotenv', {
tokenizer: { tokenizer: {
root: [ root: [
@ -104,19 +113,21 @@ monaco.languages.setMonarchTokensProvider('dotenv', {
}, },
}); });
export const Editor = forwardRef< export type EditorHandle = {
{ setValue: (value: string) => void;
setValue: (value: string) => void; };
},
React.ComponentProps<typeof MonacoEditor> & { export type EditorProps = React.ComponentProps<typeof MonacoEditor> & {
uri?: monaco.Uri; uri?: monaco.Uri;
variablesUri?: monaco.Uri; variablesUri?: monaco.Uri;
extraLibs?: string[]; extraLibs?: string[];
} };
>((props, ref) => {
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
const id = useId(); const id = useId();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const { introspection, endpoint } = useLaboratory(); const { introspection, endpoint, theme } = useLaboratory();
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
useEffect(() => { useEffect(() => {
if (introspection) { if (introspection) {
@ -147,23 +158,46 @@ export const Editor = forwardRef<
}, [introspection, props.uri?.toString(), props.variablesUri?.toString()]); }, [introspection, props.uri?.toString(), props.variablesUri?.toString()]);
useEffect(() => { useEffect(() => {
if (props.extraLibs) { void (async function () {
for (const lib of props.extraLibs) { if (!props.extraLibs?.length) {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ return;
target: monaco.languages.typescript.ScriptTarget.ESNext, // supports top-level await
module: monaco.languages.typescript.ModuleKind.ESNext, // treat file as module
allowNonTsExtensions: true,
allowJs: true,
lib: ['esnext', 'webworker'], // if running in sandbox
});
monaco.languages.typescript.typescriptDefaults.addExtraLib(
lib,
`file:///hive-lab-globals-${id}.d.ts`,
);
} }
}
}, []); if (!monaco.languages.typescript) {
await import('monaco-editor/esm/vs/language/typescript/monaco.contribution');
setTypescriptReady(true);
}
const ts = monaco.languages.typescript;
if (!ts) {
return;
}
const extraLibs = Object.values(ts.typescriptDefaults.getExtraLibs()).map(lib => lib.content);
if (props.extraLibs.every(lib => extraLibs.includes(lib))) {
return;
}
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, '_');
ts.typescriptDefaults.setCompilerOptions({
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
allowNonTsExtensions: true,
allowJs: true,
lib: ['esnext', 'webworker'],
});
ts.typescriptDefaults.setExtraLibs(
props.extraLibs.map((content, index) => ({
content,
filePath: `file:///hive-lab-globals-${safeId}-${index}.d.ts`,
})),
);
})();
}, [id, props.extraLibs]);
useImperativeHandle( useImperativeHandle(
ref, ref,
@ -177,18 +211,25 @@ export const Editor = forwardRef<
[], [],
); );
if (!typescriptReady && props.language === 'typescript') {
return null;
}
return ( return (
<div className="size-full"> <div className="size-full overflow-hidden">
<MonacoEditor <MonacoEditor
className="size-full" className="size-full"
{...props} {...props}
theme="hive-laboratory-dark" theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
onMount={editor => { onMount={editor => {
editorRef.current = editor; editorRef.current = editor;
}} }}
loading={null} loading={null}
options={{ options={{
...props.options, ...props.options,
lineNumbers: 'on',
cursorStyle: 'line',
cursorBlinking: 'smooth',
padding: { padding: {
top: 16, top: 16,
}, },
@ -206,3 +247,5 @@ export const Editor = forwardRef<
</div> </div>
); );
}); });
export const Editor = EditorInner as unknown as (props: EditorProps) => JSX.Element;

View file

@ -1,11 +1,11 @@
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { useLaboratory } from './context';
import { Editor } from '@/laboratory/components/laboratory/editor'; import { Editor } from './editor';
export const Env = () => { export const Env = () => {
const { env, setEnv } = useLaboratory(); const { env, setEnv } = useLaboratory();
return ( return (
<div className="bg-neutral-2 size-full"> <div className="bg-card size-full">
<Editor <Editor
defaultValue={Object.entries(env?.variables ?? {}) defaultValue={Object.entries(env?.variables ?? {})
.map(([key, value]) => `${key}=${value}`) .map(([key, value]) => `${key}=${value}`)

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { LaboratoryHistoryRequest } from '../../lib/history';
import { Operation } from '@/laboratory/components/laboratory/operation'; import { useLaboratory } from './context';
import { LaboratoryHistoryRequest } from '@/laboratory/lib/history'; import { Operation } from './operation';
export const HistoryItem = () => { export const HistoryItem = () => {
const { activeTab, history } = useLaboratory(); const { activeTab, history } = useLaboratory();

View file

@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ClockIcon, FolderClockIcon, FolderOpenIcon, HistoryIcon, TrashIcon } from 'lucide-react'; import { ClockIcon, FolderClockIcon, FolderOpenIcon, HistoryIcon, TrashIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import type { LaboratoryHistory, LaboratoryHistoryRequest } from '../../lib/history';
import { cn } from '../../lib/utils';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -12,24 +13,13 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/laboratory/components/ui/alert-dialog'; } from '../ui/alert-dialog';
import { Button } from '@/laboratory/components/ui/button'; import { Button } from '../ui/button';
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
Collapsible, import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
CollapsibleContent, import { ScrollArea, ScrollBar } from '../ui/scroll-area';
CollapsibleTrigger, import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
} from '@/laboratory/components/ui/collapsible'; import { useLaboratory } from './context';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import type { LaboratoryHistory, LaboratoryHistoryRequest } from '@/laboratory/lib/history';
import { cn } from '@/laboratory/lib/utils';
export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequest }) => { export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequest }) => {
const { activeTab, addTab, setActiveTab, deleteHistory } = useLaboratory(); const { activeTab, addTab, setActiveTab, deleteHistory } = useLaboratory();
@ -57,8 +47,8 @@ export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequ
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('bg-neutral-3 group sticky top-0 w-full justify-start px-2', { className={cn('bg-background group sticky top-0 w-full justify-start px-2', {
'bg-neutral-2': isActive, 'bg-accent/50': isActive,
})} })}
onClick={() => { onClick={() => {
setActiveTab( setActiveTab(
@ -78,7 +68,7 @@ export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequ
'text-red-500': isError, 'text-red-500': isError,
})} })}
/> />
<span className="text-neutral-10"> <span className="text-muted-foreground">
{format(new Date(props.historyItem.createdAt), 'HH:mm')} {format(new Date(props.historyItem.createdAt), 'HH:mm')}
</span> </span>
<div className="truncate">{props.historyItem.operation.name || 'Untitled'}</div> <div className="truncate">{props.historyItem.operation.name || 'Untitled'}</div>
@ -89,7 +79,7 @@ export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequ
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="link" variant="link"
className="text-neutral-10 p-1! pr-0! ml-auto opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100" className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -137,13 +127,13 @@ export const HistoryGroup = (props: { group: { date: string; items: LaboratoryHi
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="bg-neutral-3 group sticky top-0 w-full justify-start px-2" className="bg-background group sticky top-0 w-full justify-start px-2"
size="sm" size="sm"
> >
{isOpen ? ( {isOpen ? (
<FolderOpenIcon className="text-neutral-10 size-4" /> <FolderOpenIcon className="text-muted-foreground size-4" />
) : ( ) : (
<FolderClockIcon className="text-neutral-10 size-4" /> <FolderClockIcon className="text-muted-foreground size-4" />
)} )}
{props.group.date} {props.group.date}
<Tooltip> <Tooltip>
@ -152,7 +142,7 @@ export const HistoryGroup = (props: { group: { date: string; items: LaboratoryHi
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="link" variant="link"
className="text-neutral-10 p-1! pr-0! ml-auto opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100" className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -188,7 +178,7 @@ export const HistoryGroup = (props: { group: { date: string; items: LaboratoryHi
</Tooltip> </Tooltip>
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className={cn('border-neutral-5 ml-4 flex flex-col gap-1 border-l pl-2')}> <CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
{props.group.items.map(h => { {props.group.items.map(h => {
return <HistoryOperationItem key={h.id} historyItem={h as LaboratoryHistoryRequest} />; return <HistoryOperationItem key={h.id} historyItem={h as LaboratoryHistoryRequest} />;
})} })}
@ -239,7 +229,7 @@ export const History = () => {
return ( return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0"> <div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-neutral-5 flex h-12 items-center gap-2 border-b p-3"> <div className="border-border flex h-12 items-center gap-2 border-b p-3">
<span className="text-base font-medium">History</span> <span className="text-base font-medium">History</span>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<Tooltip> <Tooltip>
@ -249,7 +239,7 @@ export const History = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-neutral-10 p-1! size-6 rounded-sm hover:text-red-500" className="text-muted-foreground hover:text-destructive p-1! size-6 rounded-sm"
disabled={history.length === 0} disabled={history.length === 0}
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
@ -296,7 +286,7 @@ export const History = () => {
<Empty className="px-0! w-full"> <Empty className="px-0! w-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<ClockIcon className="text-neutral-10 size-6" /> <ClockIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle className="text-base">No history yet</EmptyTitle> <EmptyTitle className="text-base">No history yet</EmptyTitle>
<EmptyDescription className="text-xs"> <EmptyDescription className="text-xs">

View file

@ -1,24 +1,22 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import laboratoryStyles from '../../index.css?inline';
import { FileIcon, FoldersIcon, HistoryIcon, SettingsIcon } from 'lucide-react'; import { FileIcon, FoldersIcon, HistoryIcon, SettingsIcon } from 'lucide-react';
import monacoStyles from 'monaco-editor/min/vs/editor/editor.main.css?inline';
import * as z from 'zod'; import * as z from 'zod';
import { Markdown } from '@/components/v2/markdown'; import { useForm } from '@tanstack/react-form';
import { Collections } from '@/laboratory/components/laboratory/collections'; import { useCollections } from '../../lib/collections';
import { Command } from '@/laboratory/components/laboratory/command'; import { useEndpoint } from '../../lib/endpoint';
import { import { useEnv } from '../../lib/env';
LaboratoryPermission, import { useHistory } from '../../lib/history';
LaboratoryPermissions, import { useOperations } from '../../lib/operations';
LaboratoryProvider, import { LaboratoryPluginTab, usePlugins } from '../../lib/plugins';
useLaboratory, import { usePreflight } from '../../lib/preflight';
type LaboratoryApi, import { useSettings } from '../../lib/settings';
} from '@/laboratory/components/laboratory/context'; import { LaboratoryTabCustom, useTabs } from '../../lib/tabs';
import { Env } from '@/laboratory/components/laboratory/env'; import { useTests } from '../../lib/tests';
import { History } from '@/laboratory/components/laboratory/history'; import { cn } from '../../lib/utils';
import { HistoryItem } from '@/laboratory/components/laboratory/history-item'; import { Button } from '../ui/button';
import { Operation } from '@/laboratory/components/laboratory/operation';
import { Preflight } from '@/laboratory/components/laboratory/preflight';
import { Settings } from '@/laboratory/components/laboratory/settings';
import { Tabs } from '@/laboratory/components/laboratory/tabs';
import { Button } from '@/laboratory/components/ui/button';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -27,7 +25,7 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/laboratory/components/ui/dialog'; } from '../ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -36,7 +34,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/laboratory/components/ui/dropdown-menu'; } from '../ui/dropdown-menu';
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@ -44,34 +42,59 @@ import {
EmptyHeader, EmptyHeader,
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '@/laboratory/components/ui/empty'; } from '../ui/empty';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
import { Toaster } from '../ui/sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Collections } from './collections';
import { Command } from './command';
import { import {
Field, LaboratoryPermission,
FieldDescription, LaboratoryPermissions,
FieldError, LaboratoryProvider,
FieldGroup, useLaboratory,
FieldLabel, type LaboratoryApi,
} from '@/laboratory/components/ui/field'; } from './context';
import { Input } from '@/laboratory/components/ui/input'; import { Env } from './env';
import { import { History } from './history';
ResizableHandle, import { HistoryItem } from './history-item';
ResizablePanel, import { Operation } from './operation';
ResizablePanelGroup, import { Preflight } from './preflight';
} from '@/laboratory/components/ui/resizable'; import { Settings } from './settings';
import { Toaster } from '@/laboratory/components/ui/sonner'; import { Tabs } from './tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import { useCollections } from '@/laboratory/lib/collections'; const ShadowRootContainer = (props: { children: ReactNode }) => {
import { useEndpoint } from '@/laboratory/lib/endpoint'; const hostRef = useRef<HTMLDivElement | null>(null);
import { useEnv } from '@/laboratory/lib/env'; const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);
import { useHistory } from '@/laboratory/lib/history';
import { useOperations } from '@/laboratory/lib/operations'; useLayoutEffect(() => {
import { LaboratoryPluginTab, usePlugins } from '@/laboratory/lib/plugins'; if (!hostRef.current || shadowRoot) {
import { usePreflight } from '@/laboratory/lib/preflight'; return;
import { useSettings } from '@/laboratory/lib/settings'; }
import { LaboratoryTabCustom, useTabs } from '@/laboratory/lib/tabs';
import { useTests } from '@/laboratory/lib/tests'; setShadowRoot(hostRef.current.attachShadow({ mode: 'open' }));
import { cn } from '@/laboratory/lib/utils'; }, [shadowRoot]);
import { useForm } from '@tanstack/react-form';
useLayoutEffect(() => {
if (!shadowRoot) {
return;
}
document.head.querySelectorAll('style').forEach(style => {
if (style.textContent?.includes('[data-sonner-toaster]')) {
shadowRoot.append(style.cloneNode(true));
}
});
}, [shadowRoot]);
return (
<div ref={hostRef} className="hive-laboratory-host size-full">
{shadowRoot ? createPortal(props.children, shadowRoot) : null}
</div>
);
};
const addCollectionFormSchema = z.object({ const addCollectionFormSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@ -88,9 +111,7 @@ const addTestFormSchema = z.object({
const PreflightPromptModal = (props: { const PreflightPromptModal = (props: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
title?: string; placeholder: string;
description?: string;
placeholder?: string;
defaultValue?: string; defaultValue?: string;
onSubmit?: (value: string | null) => void; onSubmit?: (value: string | null) => void;
}) => { }) => {
@ -125,6 +146,7 @@ const PreflightPromptModal = (props: {
<DialogHeader> <DialogHeader>
<DialogTitle>Preflight prompt</DialogTitle> <DialogTitle>Preflight prompt</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription>Enter values for the preflight script.</DialogDescription>
<form <form
id="preflight-prompt-form" id="preflight-prompt-form"
onSubmit={e => { onSubmit={e => {
@ -136,15 +158,8 @@ const PreflightPromptModal = (props: {
<form.Field name="value"> <form.Field name="value">
{field => { {field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return ( return (
<Field data-invalid={isInvalid}> <Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>{props.title}</FieldLabel>
{props.description && (
<FieldDescription>
<Markdown content={props.description} />
</FieldDescription>
)}
<Input <Input
id={field.name} id={field.name}
name={field.name} name={field.name}
@ -238,7 +253,7 @@ const LaboratoryContent = () => {
<Empty className="px-0! w-full"> <Empty className="px-0! w-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<FileIcon className="text-neutral-10 size-6" /> <FileIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No operation selected</EmptyTitle> <EmptyTitle>No operation selected</EmptyTitle>
<EmptyDescription> <EmptyDescription>
@ -285,7 +300,7 @@ const LaboratoryContent = () => {
className={cn( className={cn(
'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent', 'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{ {
'border-neutral-11': activePanel === 'collections', 'border-primary': activePanel === 'collections',
}, },
)} )}
> >
@ -293,8 +308,8 @@ const LaboratoryContent = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setActivePanel(activePanel === 'collections' ? null : 'collections')} onClick={() => setActivePanel(activePanel === 'collections' ? null : 'collections')}
className={cn('text-neutral-10 hover:text-neutral-11', { className={cn('text-muted-foreground hover:text-foreground', {
'text-neutral-11': activePanel === 'collections', 'text-foreground': activePanel === 'collections',
})} })}
> >
<FoldersIcon className="size-5" /> <FoldersIcon className="size-5" />
@ -309,7 +324,7 @@ const LaboratoryContent = () => {
className={cn( className={cn(
'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent', 'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{ {
'border-neutral-11': activePanel === 'history', 'border-primary': activePanel === 'history',
}, },
)} )}
> >
@ -317,8 +332,8 @@ const LaboratoryContent = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')} onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')}
className={cn('text-neutral-10 hover:text-neutral-11', { className={cn('text-muted-foreground hover:text-foreground', {
'text-neutral-11': activePanel === 'history', 'text-foreground': activePanel === 'history',
})} })}
> >
<HistoryIcon className="size-5" /> <HistoryIcon className="size-5" />
@ -331,7 +346,7 @@ const LaboratoryContent = () => {
className={cn( className={cn(
'relative z-10 mt-auto flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent', 'relative z-10 mt-auto flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{ {
'border-neutral-11': activePanel === 'settings', 'border-primary': activePanel === 'settings',
}, },
)} )}
> >
@ -343,8 +358,8 @@ const LaboratoryContent = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')} onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')}
className={cn('text-neutral-10 hover:text-neutral-11', { className={cn('text-muted-foreground hover:text-foreground', {
'text-neutral-11': activePanel === 'history', 'text-foreground': activePanel === 'history',
})} })}
> >
<SettingsIcon className="size-5" /> <SettingsIcon className="size-5" />
@ -418,7 +433,7 @@ const LaboratoryContent = () => {
<div className="w-full"> <div className="w-full">
<Tabs /> <Tabs />
</div> </div>
<div className="bg-neutral-3 flex-1 overflow-hidden">{contentNode}</div> <div className="bg-card flex-1 overflow-hidden">{contentNode}</div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
@ -428,46 +443,50 @@ const LaboratoryContent = () => {
export type LaboratoryProps = LaboratoryApi; export type LaboratoryProps = LaboratoryApi;
export const Laboratory = ( export const Laboratory = (
props: Pick< props: Partial<
LaboratoryProps, Pick<
| 'permissions' LaboratoryProps,
| 'defaultEndpoint' | 'permissions'
| 'onEndpointChange' | 'defaultEndpoint'
| 'defaultCollections' | 'onEndpointChange'
| 'onCollectionsChange' | 'defaultCollections'
| 'onCollectionCreate' | 'onCollectionsChange'
| 'onCollectionUpdate' | 'onCollectionCreate'
| 'onCollectionDelete' | 'onCollectionUpdate'
| 'onCollectionOperationCreate' | 'onCollectionDelete'
| 'onCollectionOperationUpdate' | 'onCollectionOperationCreate'
| 'onCollectionOperationDelete' | 'onCollectionOperationUpdate'
| 'defaultOperations' | 'onCollectionOperationDelete'
| 'onOperationsChange' | 'defaultOperations'
| 'defaultActiveOperationId' | 'onOperationsChange'
| 'onActiveOperationIdChange' | 'defaultActiveOperationId'
| 'onOperationCreate' | 'onActiveOperationIdChange'
| 'onOperationUpdate' | 'onOperationCreate'
| 'onOperationDelete' | 'onOperationUpdate'
| 'defaultHistory' | 'onOperationDelete'
| 'onHistoryChange' | 'defaultHistory'
| 'onHistoryCreate' | 'onHistoryChange'
| 'onHistoryUpdate' | 'onHistoryCreate'
| 'onHistoryDelete' | 'onHistoryUpdate'
| 'defaultTabs' | 'onHistoryDelete'
| 'onTabsChange' | 'defaultTabs'
| 'defaultPreflight' | 'onTabsChange'
| 'onPreflightChange' | 'defaultPreflight'
| 'defaultEnv' | 'onPreflightChange'
| 'onEnvChange' | 'defaultEnv'
| 'defaultActiveTabId' | 'onEnvChange'
| 'onActiveTabIdChange' | 'defaultActiveTabId'
| 'defaultSettings' | 'onActiveTabIdChange'
| 'onSettingsChange' | 'defaultSettings'
| 'defaultTests' | 'onSettingsChange'
| 'onTestsChange' | 'defaultTests'
| 'plugins' | 'onTestsChange'
| 'defaultPluginsState' | 'plugins'
| 'onPluginsStateChange' | 'defaultPluginsState'
| 'onPluginsStateChange'
| 'theme'
| 'defaultSchemaIntrospection'
>
>, >,
) => { ) => {
const checkPermissions = useCallback( const checkPermissions = useCallback(
@ -485,51 +504,11 @@ export const Laboratory = (
[props.permissions], [props.permissions],
); );
const [isPreflightPromptModalOpen, setIsPreflightPromptModalOpen] = useState(false);
const [preflightPromptModalProps, setPreflightPromptModalProps] = useState<{
title?: string;
description?: string;
placeholder?: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}>({
title: undefined,
description: undefined,
placeholder: undefined,
defaultValue: undefined,
onSubmit: undefined,
});
const openPreflightPromptModal = useCallback(
(props: {
title?: string;
description?: string;
placeholder?: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
setPreflightPromptModalProps({
title: props.title,
description: props.description,
placeholder: props.placeholder,
defaultValue: props.defaultValue,
onSubmit: props.onSubmit,
});
setTimeout(() => {
setIsPreflightPromptModalOpen(true);
}, 200);
},
[],
);
const settingsApi = useSettings(props); const settingsApi = useSettings(props);
const envApi = useEnv(props); const envApi = useEnv(props);
const preflightApi = usePreflight({ const preflightApi = usePreflight({
...props, ...props,
envApi, envApi,
openPreflightPromptModal,
}); });
const pluginsApi = usePlugins(props); const pluginsApi = usePlugins(props);
@ -613,6 +592,37 @@ export const Laboratory = (
}, },
}); });
const [isPreflightPromptModalOpen, setIsPreflightPromptModalOpen] = useState(false);
const [preflightPromptModalProps, setPreflightPromptModalProps] = useState<{
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}>({
placeholder: '',
defaultValue: undefined,
onSubmit: undefined,
});
const openPreflightPromptModal = useCallback(
(props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
setPreflightPromptModalProps({
placeholder: props.placeholder,
defaultValue: props.defaultValue,
onSubmit: props.onSubmit,
});
setTimeout(() => {
setIsPreflightPromptModalOpen(true);
}, 200);
},
[],
);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false);
@ -626,193 +636,192 @@ export const Laboratory = (
}, []); }, []);
return ( return (
<div <ShadowRootContainer>
className={cn('hive-laboratory bg-neutral-3 size-full', { <style>{`${laboratoryStyles}\n${monacoStyles}`}</style>
'fixed inset-0 z-50': isFullScreen, <div
})} className={cn('hive-laboratory bg-background size-full', props.theme, {
style={ 'fixed inset-0 z-50': isFullScreen,
{ })}
'--color-primary': 'var(--color-orange-500)', ref={containerRef}
} as React.CSSProperties
}
ref={containerRef}
>
<Toaster richColors closeButton position="top-right" />
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update endpoint</DialogTitle>
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="update-endpoint-form"
onSubmit={e => {
e.preventDefault();
void updateEndpointForm.handleSubmit();
}}
>
<FieldGroup>
<updateEndpointForm.Field name="endpoint">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter endpoint"
autoComplete="off"
/>
);
}}
</updateEndpointForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="update-endpoint-form">
Update endpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PreflightPromptModal
open={isPreflightPromptModalOpen}
onOpenChange={setIsPreflightPromptModalOpen}
{...preflightPromptModalProps}
/>
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-collection-form"
onSubmit={e => {
e.preventDefault();
void addCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<addCollectionForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-collection-form">
Add collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add test</DialogTitle>
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-test-form"
onSubmit={e => {
e.preventDefault();
void addTestForm.handleSubmit();
}}
>
<FieldGroup>
<addTestForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the test"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addTestForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-test-form">
Add test
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaboratoryProvider
{...props}
{...testsApi}
{...settingsApi}
{...pluginsApi}
{...envApi}
{...preflightApi}
{...tabsApi}
{...endpointApi}
{...collectionsApi}
{...operationsApi}
{...historyApi}
openAddCollectionDialog={openAddCollectionDialog}
openUpdateEndpointDialog={openUpdateEndpointDialog}
openAddTestDialog={openAddTestDialog}
openPreflightPromptModal={openPreflightPromptModal}
goToFullScreen={goToFullScreen}
exitFullScreen={exitFullScreen}
isFullScreen={isFullScreen}
checkPermissions={checkPermissions}
> >
<LaboratoryContent /> <Toaster richColors closeButton position="top-right" theme={props.theme} />
</LaboratoryProvider> <Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
</div> <DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update endpoint</DialogTitle>
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="update-endpoint-form"
onSubmit={e => {
e.preventDefault();
void updateEndpointForm.handleSubmit();
}}
>
<FieldGroup>
<updateEndpointForm.Field name="endpoint">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter endpoint"
autoComplete="off"
/>
);
}}
</updateEndpointForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="update-endpoint-form">
Update endpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PreflightPromptModal
open={isPreflightPromptModalOpen}
onOpenChange={setIsPreflightPromptModalOpen}
{...preflightPromptModalProps}
/>
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-collection-form"
onSubmit={e => {
e.preventDefault();
void addCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<addCollectionForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-collection-form">
Add collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add test</DialogTitle>
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-test-form"
onSubmit={e => {
e.preventDefault();
void addTestForm.handleSubmit();
}}
>
<FieldGroup>
<addTestForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the test"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addTestForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-test-form">
Add test
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaboratoryProvider
{...props}
{...testsApi}
{...settingsApi}
{...pluginsApi}
{...envApi}
{...preflightApi}
{...tabsApi}
{...endpointApi}
{...collectionsApi}
{...operationsApi}
{...historyApi}
container={containerRef.current}
openAddCollectionDialog={openAddCollectionDialog}
openUpdateEndpointDialog={openUpdateEndpointDialog}
openAddTestDialog={openAddTestDialog}
openPreflightPromptModal={openPreflightPromptModal}
goToFullScreen={goToFullScreen}
exitFullScreen={exitFullScreen}
isFullScreen={isFullScreen}
checkPermissions={checkPermissions}
>
<LaboratoryContent />
</LaboratoryProvider>
</div>
</ShadowRootContainer>
); );
}; };

View file

@ -16,11 +16,17 @@ import { compressToEncodedURIComponent } from 'lz-string';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import { Builder } from '@/laboratory/components/laboratory/builder'; import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { useForm } from '@tanstack/react-form';
import { Editor } from '@/laboratory/components/laboratory/editor'; import type {
import { Badge } from '@/laboratory/components/ui/badge'; LaboratoryHistory,
import { Button } from '@/laboratory/components/ui/button'; LaboratoryHistoryRequest,
LaboratoryHistorySubscription,
} from '../../lib/history';
import type { LaboratoryOperation } from '../../lib/operations';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -29,45 +35,19 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/laboratory/components/ui/dialog'; } from '../ui/dialog';
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
DropdownMenu, import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
DropdownMenuContent, import { Field, FieldGroup, FieldLabel } from '../ui/field';
DropdownMenuItem, import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
} from '@/laboratory/components/ui/dropdown-menu'; import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
Empty, import { Spinner } from '../ui/spinner';
EmptyDescription, import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
EmptyHeader, import { Toggle } from '../ui/toggle';
EmptyMedia, import { Builder } from './builder';
EmptyTitle, import { useLaboratory } from './context';
} from '@/laboratory/components/ui/empty'; import { Editor } from './editor';
import { Field, FieldGroup, FieldLabel } from '@/laboratory/components/ui/field';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/laboratory/components/ui/resizable';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/laboratory/components/ui/select';
import { Spinner } from '@/laboratory/components/ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/laboratory/components/ui/tabs';
import { Toggle } from '@/laboratory/components/ui/toggle';
import type {
LaboratoryHistory,
LaboratoryHistoryRequest,
LaboratoryHistorySubscription,
} from '@/laboratory/lib/history';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import { cn } from '@/laboratory/lib/utils';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { useForm } from '@tanstack/react-form';
const variablesUri = monaco.Uri.file('variables.json'); const variablesUri = monaco.Uri.file('variables.json');
@ -178,14 +158,14 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
<div className="flex flex-col gap-1.5 whitespace-pre-wrap p-3"> <div className="flex flex-col gap-1.5 whitespace-pre-wrap p-3">
{historyItem?.preflightLogs?.map((log, i) => ( {historyItem?.preflightLogs?.map((log, i) => (
<div className="gap-2 font-mono" key={i}> <div className="gap-2 font-mono" key={i}>
<span className="text-neutral-10 text-xs">{log.createdAt}</span>{' '} <span className="text-muted-foreground text-xs">{log.createdAt}</span>{' '}
<span <span
className={cn('text-xs font-medium', { className={cn('text-xs font-medium', {
'text-blue-400': log.level === 'info', 'text-blue-400': log.level === 'info',
'text-green-400': log.level === 'log', 'text-green-400': log.level === 'log',
'text-yellow-400': log.level === 'warn', 'text-yellow-400': log.level === 'warn',
'text-red-400': log.level === 'error', 'text-red-400': log.level === 'error',
'text-neutral-10': log.level === 'system', 'text-gray-400': log.level === 'system',
})} })}
> >
{log.level.toUpperCase()} {log.level.toUpperCase()}
@ -208,7 +188,7 @@ export const ResponseSubscription = ({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="border-neutral-5 flex h-12 border-b p-3 text-base font-medium"> <div className="border-border flex h-12 border-b p-3 text-base font-medium">
Subscription Subscription
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isActiveOperationLoading ? ( {isActiveOperationLoading ? (
@ -224,7 +204,7 @@ export const ResponseSubscription = ({
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="flex flex-col"> <div className="flex flex-col overflow-hidden">
{historyItem?.responses {historyItem?.responses
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((response, i) => { .map((response, i) => {
@ -237,11 +217,7 @@ export const ResponseSubscription = ({
const height = 20.5 * value.split('\n').length; const height = 20.5 * value.split('\n').length;
return ( return (
<div <div className="border-border border-b" style={{ height: `${height}px` }} key={i}>
className="border-neutral-5 border-b"
style={{ height: `${height}px` }}
key={i}
>
<Editor <Editor
key={response.createdAt} key={response.createdAt}
value={value} value={value}
@ -316,7 +292,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
</Badge> </Badge>
)} )}
{historyItem?.duration && ( {historyItem?.duration && (
<Badge variant="outline" className="bg-neutral-2"> <Badge variant="outline" className="bg-card">
<ClockIcon className="size-3" /> <ClockIcon className="size-3" />
<span> <span>
{Math.round((historyItem as LaboratoryHistoryRequest).duration!)} {Math.round((historyItem as LaboratoryHistoryRequest).duration!)}
@ -325,7 +301,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
</Badge> </Badge>
)} )}
{historyItem?.size && ( {historyItem?.size && (
<Badge variant="outline" className="bg-neutral-2"> <Badge variant="outline" className="bg-card">
<FileTextIcon className="size-3" /> <FileTextIcon className="size-3" />
<span> <span>
{Math.round((historyItem as LaboratoryHistoryRequest).size! / 1024)} {Math.round((historyItem as LaboratoryHistoryRequest).size! / 1024)}
@ -336,13 +312,13 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
</div> </div>
) : null} ) : null}
</TabsList> </TabsList>
<TabsContent value="response"> <TabsContent value="response" className="overflow-hidden">
<ResponseBody historyItem={historyItem} /> <ResponseBody historyItem={historyItem} />
</TabsContent> </TabsContent>
<TabsContent value="headers"> <TabsContent value="headers" className="overflow-hidden">
<ResponseHeaders historyItem={historyItem} /> <ResponseHeaders historyItem={historyItem} />
</TabsContent> </TabsContent>
<TabsContent value="preflight"> <TabsContent value="preflight" className="overflow-hidden">
<ResponsePreflight historyItem={historyItem} /> <ResponsePreflight historyItem={historyItem} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@ -549,7 +525,7 @@ export const Query = (props: {
); );
return ( return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0"> <div className="grid size-full grid-rows-[auto_1fr] overflow-hidden pb-0">
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}> <Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
@ -607,7 +583,7 @@ export const Query = (props: {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className="border-neutral-5 flex w-full items-center gap-2 border-b p-3"> <div className="border-border flex w-full items-center gap-2 overflow-hidden border-b p-3">
<span className="text-base font-medium">Operation</span> <span className="text-base font-medium">Operation</span>
{checkPermissions?.('collectionsOperations:create') && ( {checkPermissions?.('collectionsOperations:create') && (
<Toggle <Toggle
@ -650,7 +626,7 @@ export const Query = (props: {
size="sm" size="sm"
variant="default" variant="default"
pressed={preflight?.enabled} pressed={preflight?.enabled}
className="bg-neutral-3 hover:bg-neutral-2 hover:text-neutral-12 h-6 rounded-sm border shadow-sm data-[state=on]:bg-transparent" className="hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50 h-6 rounded-sm border shadow-sm data-[state=on]:bg-transparent"
onClick={() => { onClick={() => {
setPreflight({ setPreflight({
...(preflight ?? { script: '', enabled: true }), ...(preflight ?? { script: '', enabled: true }),
@ -715,23 +691,21 @@ export const Query = (props: {
)} )}
</div> </div>
</div> </div>
<div className="size-full"> <Editor
<Editor uri={monaco.Uri.file(`operation_${endpoint}.graphql`)}
uri={monaco.Uri.file(`operation_${endpoint}.graphql`)} variablesUri={variablesUri}
variablesUri={variablesUri} value={operation?.query ?? ''}
value={operation?.query ?? ''} onChange={value => {
onChange={value => { updateActiveOperation({
updateActiveOperation({ query: value ?? '',
query: value ?? '', });
}); }}
}} language="graphql"
language="graphql" theme="hive-laboratory"
theme="hive-laboratory" options={{
options={{ readOnly: props.isReadOnly,
readOnly: props.isReadOnly, }}
}} />
/>
</div>
</div> </div>
); );
}; };
@ -761,7 +735,7 @@ export const Operation = (props: {
}, [props.historyItem]); }, [props.historyItem]);
return ( return (
<div className="bg-neutral-2 size-full"> <div className="bg-card size-full">
<ResizablePanelGroup direction="horizontal" className="size-full"> <ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={25}> <ResizablePanel defaultSize={25}>
<Builder operation={operation} isReadOnly={isReadOnly} /> <Builder operation={operation} isReadOnly={isReadOnly} />
@ -773,7 +747,7 @@ export const Operation = (props: {
<Query operation={operation} isReadOnly={isReadOnly} /> <Query operation={operation} isReadOnly={isReadOnly} />
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30} className="overflow-visible!"> <ResizablePanel minSize={10} defaultSize={30}>
<Tabs className="grid size-full grid-rows-[auto_1fr]" defaultValue="variables"> <Tabs className="grid size-full grid-rows-[auto_1fr]" defaultValue="variables">
<TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3"> <TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3">
<TabsTrigger value="variables" className="grow-0 rounded-sm"> <TabsTrigger value="variables" className="grow-0 rounded-sm">
@ -786,13 +760,13 @@ export const Operation = (props: {
Extensions Extensions
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="variables"> <TabsContent value="variables" className="overflow-hidden">
<Variables operation={operation} isReadOnly={isReadOnly} /> <Variables operation={operation} isReadOnly={isReadOnly} />
</TabsContent> </TabsContent>
<TabsContent value="headers"> <TabsContent value="headers" className="overflow-hidden">
<Headers operation={operation} isReadOnly={isReadOnly} /> <Headers operation={operation} isReadOnly={isReadOnly} />
</TabsContent> </TabsContent>
<TabsContent value="extensions"> <TabsContent value="extensions" className="overflow-hidden">
<Extensions operation={operation} isReadOnly={isReadOnly} /> <Extensions operation={operation} isReadOnly={isReadOnly} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@ -813,7 +787,7 @@ export const Operation = (props: {
<Empty className="size-full"> <Empty className="size-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<HistoryIcon className="text-neutral-10 size-6" /> <HistoryIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No history yet</EmptyTitle> <EmptyTitle>No history yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>

View file

@ -1,23 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { HistoryIcon, PlayIcon } from 'lucide-react'; import { HistoryIcon, PlayIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { runIsolatedLabScript } from '../../lib/preflight';
import { Editor } from '@/laboratory/components/laboratory/editor'; import { cn } from '../../lib/utils';
import { Button } from '@/laboratory/components/ui/button'; import { Button } from '../ui/button';
import { import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
Empty, import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
EmptyDescription, import { ScrollArea, ScrollBar } from '../ui/scroll-area';
EmptyHeader, import { useLaboratory } from './context';
EmptyMedia, import { Editor } from './editor';
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/laboratory/components/ui/resizable';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { runIsolatedLabScript } from '@/laboratory/lib/preflight';
import { cn } from '@/laboratory/lib/utils';
export const Preflight = () => { export const Preflight = () => {
const { const {
@ -41,12 +31,10 @@ export const Preflight = () => {
const result = await runIsolatedLabScript( const result = await runIsolatedLabScript(
preflight?.script ?? '', preflight?.script ?? '',
env ?? { variables: {} }, env ?? { variables: {} },
(title, defaultValue, options) => { (placeholder, defaultValue) => {
return new Promise(resolve => { return new Promise(resolve => {
openPreflightPromptModal?.({ openPreflightPromptModal?.({
title, placeholder,
description: options?.description,
placeholder: options?.placeholder,
defaultValue, defaultValue,
onSubmit: value => { onSubmit: value => {
resolve(value); resolve(value);
@ -65,9 +53,9 @@ export const Preflight = () => {
return ( return (
<ResizablePanelGroup direction="horizontal" className="size-full"> <ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={50} className="bg-neutral-2"> <ResizablePanel defaultSize={50} className="bg-card">
<div className="grid size-full grid-rows-[auto_1fr] pb-0"> <div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-neutral-5 flex w-full items-center gap-2 border-b p-3"> <div className="border-border flex w-full items-center gap-2 border-b p-3">
<span className="text-base font-medium">Preflight</span> <span className="text-base font-medium">Preflight</span>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<Button variant="default" size="sm" className="h-6 rounded-sm" onClick={run}> <Button variant="default" size="sm" className="h-6 rounded-sm" onClick={run}>
@ -97,7 +85,7 @@ export const Preflight = () => {
request: { request: {
headers: Headers; headers: Headers;
}; };
prompt: (title: string, defaultValue: string, options?: { placeholder?: string; description?: string }) => Promise<string | null>; prompt: (placeholder: string, defaultValue: string) => Promise<string | null>;
CryptoJS: typeof CryptoJS; CryptoJS: typeof CryptoJS;
plugins: { plugins: {
${plugins ${plugins
@ -253,10 +241,10 @@ export const Preflight = () => {
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel minSize={10} defaultSize={50} className="bg-neutral-2"> <ResizablePanel minSize={10} defaultSize={50} className="bg-card">
{preflight?.lastTestResult?.logs && preflight?.lastTestResult?.logs.length > 0 ? ( {preflight?.lastTestResult?.logs && preflight?.lastTestResult?.logs.length > 0 ? (
<div className="grid size-full grid-rows-[auto_1fr] pb-0"> <div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-neutral-5 flex h-12 w-full items-center gap-2 border-b p-3"> <div className="border-border flex h-12 w-full items-center gap-2 border-b p-3">
<span className="text-base font-medium">Logs</span> <span className="text-base font-medium">Logs</span>
<div className="ml-auto flex items-center gap-2" /> <div className="ml-auto flex items-center gap-2" />
</div> </div>
@ -264,14 +252,14 @@ export const Preflight = () => {
<div className="flex flex-col gap-1.5 whitespace-pre-wrap p-3"> <div className="flex flex-col gap-1.5 whitespace-pre-wrap p-3">
{preflight?.lastTestResult?.logs.map((log, i) => ( {preflight?.lastTestResult?.logs.map((log, i) => (
<div className="gap-2 font-mono" key={i}> <div className="gap-2 font-mono" key={i}>
<span className="text-neutral-10 text-xs">{log.createdAt}</span>{' '} <span className="text-muted-foreground text-xs">{log.createdAt}</span>{' '}
<span <span
className={cn('text-xs font-medium', { className={cn('text-xs font-medium', {
'text-blue-400': log.level === 'info', 'text-blue-400': log.level === 'info',
'text-green-400': log.level === 'log', 'text-green-400': log.level === 'log',
'text-yellow-400': log.level === 'warn', 'text-yellow-400': log.level === 'warn',
'text-red-400': log.level === 'error', 'text-red-400': log.level === 'error',
'text-neutral-10': log.level === 'system', 'text-gray-400': log.level === 'system',
})} })}
> >
{log.level.toUpperCase()} {log.level.toUpperCase()}
@ -287,7 +275,7 @@ export const Preflight = () => {
<Empty className="size-full"> <Empty className="size-full">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<HistoryIcon className="text-neutral-10 size-6" /> <HistoryIcon className="text-muted-foreground size-6" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No logs yet</EmptyTitle> <EmptyTitle>No logs yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>

View file

@ -1,21 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/laboratory/components/ui/card';
import { Field, FieldGroup, FieldLabel } from '@/laboratory/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/laboratory/components/ui/select';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldGroup, FieldLabel } from '../ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { useLaboratory } from './context';
const settingsFormSchema = z.object({ const settingsFormSchema = z.object({
fetch: z.object({ fetch: z.object({
@ -37,7 +25,7 @@ export const Settings = () => {
}); });
return ( return (
<div className="bg-neutral-2 size-full p-3"> <div className="bg-card size-full p-3">
<form <form
id="settings-form" id="settings-form"
onSubmit={form.handleSubmit} onSubmit={form.handleSubmit}

View file

@ -13,28 +13,28 @@ import {
SettingsIcon, SettingsIcon,
XIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
import { GraphQLIcon } from '@/laboratory/components/icons'; import { getOperationName, getOperationType } from '../../lib/operations.utils';
import { useLaboratory } from '@/laboratory/components/laboratory/context'; import { LaboratoryPluginTab } from '../../lib/plugins';
import { Button } from '@/laboratory/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/laboratory/components/ui/context-menu';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import * as Sortable from '@/laboratory/components/ui/sortable';
import { Spinner } from '@/laboratory/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import { getOperationName, getOperationType } from '@/laboratory/lib/operations.utils';
import { LaboratoryPluginTab } from '@/laboratory/lib/plugins';
import type { import type {
LaboratoryTab, LaboratoryTab,
LaboratoryTabHistory, LaboratoryTabHistory,
LaboratoryTabOperation, LaboratoryTabOperation,
LaboratoryTabTest, LaboratoryTabTest,
} from '@/laboratory/lib/tabs'; } from '../../lib/tabs';
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../../lib/utils';
import { GraphQLIcon } from '../icons';
import { Button } from '../ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '../ui/context-menu';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import * as Sortable from '../ui/sortable';
import { Spinner } from '../ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useLaboratory } from './context';
export const Tab = (props: { export const Tab = (props: {
item: LaboratoryTab; item: LaboratoryTab;
@ -213,7 +213,7 @@ export const Tab = (props: {
} }
if (props.item.type === 'settings') { if (props.item.type === 'settings') {
return <SettingsIcon className="text-neutral-10 size-4" />; return <SettingsIcon className="size-4 text-gray-400" />;
} }
if (props.item.type === 'test') { if (props.item.type === 'test') {
@ -239,7 +239,7 @@ export const Tab = (props: {
return customTab.icon; return customTab.icon;
} }
return <FileIcon className="text-neutral-8 size-4" />; return <FileIcon className="text-muted-foreground size-4" />;
}, [props.item, isError]); }, [props.item, isError]);
return ( return (
@ -250,7 +250,7 @@ export const Tab = (props: {
asHandle asHandle
className={cn( className={cn(
'data-dragging:opacity-0 flex h-12 w-max items-stretch', 'data-dragging:opacity-0 flex h-12 w-max items-stretch',
props.isOverlay && 'bg-neutral-3', props.isOverlay && 'bg-background',
props.isOverlay && !isActive && 'h-12', props.isOverlay && !isActive && 'h-12',
)} )}
> >
@ -278,9 +278,8 @@ export const Tab = (props: {
> >
<div <div
className={cn( className={cn(
'text-neutral-10 hover:text-neutral-11 group relative flex h-full cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 text-sm transition-all', 'text-muted-foreground hover:text-foreground group relative flex h-full cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 text-sm transition-all',
props.activeTab?.id === props.item.id && props.activeTab?.id === props.item.id && 'border-primary bg-card text-foreground',
'border-neutral-11 bg-neutral-2 text-neutral-11',
)} )}
onClick={() => { onClick={() => {
props.setActiveTab(props.item); props.setActiveTab(props.item);
@ -297,9 +296,9 @@ export const Tab = (props: {
{tabIcon} {tabIcon}
{tabName} {tabName}
{props.isOperationLoading(props.item.id) && <Spinner className="size-3" />} {props.isOperationLoading(props.item.id) && <Spinner className="size-3" />}
{props.item.readOnly && <LockIcon className="text-neutral-10 size-3" />} {props.item.readOnly && <LockIcon className="size-3 text-gray-400" />}
<XIcon <XIcon
className="text-neutral-10 size-3" className="text-muted-foreground size-3"
onMouseDown={e => { onMouseDown={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -310,7 +309,7 @@ export const Tab = (props: {
/> />
</div> </div>
</div> </div>
<div className="bg-neutral-5 mb-px w-px" /> <div className="bg-border mb-px w-px" />
</Sortable.Item> </Sortable.Item>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
@ -412,7 +411,7 @@ export const Tabs = ({ className }: { className?: string }) => {
<div <div
className={cn('relative z-10 grid size-full grid-cols-[1fr_auto] overflow-hidden', className)} className={cn('relative z-10 grid size-full grid-cols-[1fr_auto] overflow-hidden', className)}
> >
<div className="bg-neutral-5 absolute bottom-0 left-0 -z-10 h-px w-full" /> <div className="bg-border absolute bottom-0 left-0 -z-10 h-px w-full" />
<div className="overflow-hidden"> <div className="overflow-hidden">
<ScrollArea className="size-full whitespace-nowrap"> <ScrollArea className="size-full whitespace-nowrap">
<div className="flex items-stretch"> <div className="flex items-stretch">
@ -467,7 +466,7 @@ export const Tabs = ({ className }: { className?: string }) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-neutral-11 hover:text-neutral-11" className="text-primary hover:text-primary"
onClick={handleAddOperation} onClick={handleAddOperation}
> >
<CirclePlus className="size-4" /> <CirclePlus className="size-4" />

View file

@ -1,5 +1,5 @@
import { Children, Fragment, useEffect, useMemo, useState } from 'react'; import { Children, Fragment, useEffect, useMemo, useState } from 'react';
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../lib/utils';
interface ItemProps { interface ItemProps {
label: string; label: string;
@ -36,16 +36,16 @@ export const Tabs = ({ children, suffix }: TabsProps) => {
return ( return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0"> <div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="bg-neutral-3 relative z-10 flex h-12 w-full items-center overflow-hidden"> <div className="bg-background relative z-10 flex h-12 w-full items-center overflow-hidden">
<div className="bg-neutral-5 absolute bottom-0 left-0 -z-10 h-px w-full" /> <div className="bg-border absolute bottom-0 left-0 -z-10 h-px w-full" />
<div className="flex h-full w-max items-stretch"> <div className="flex h-full w-max items-stretch">
{Children.map(filteredChildren, child => ( {Children.map(filteredChildren, child => (
<Fragment key={child?.props.label}> <Fragment key={child?.props.label}>
<div <div
className={cn( className={cn(
'text-neutral-10 hover:text-neutral-11 group relative flex cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 font-medium transition-all', 'text-muted-foreground hover:text-foreground group relative flex cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 font-medium transition-all',
{ {
'border-neutral-11 bg-neutral-2 text-neutral-11': 'border-primary bg-card text-foreground-primary':
activeTab === child.props.label, activeTab === child.props.label,
}, },
)} )}
@ -53,7 +53,7 @@ export const Tabs = ({ children, suffix }: TabsProps) => {
> >
{child.props.label} {child.props.label}
</div> </div>
<div className="bg-neutral-5 mb-px w-px" /> <div className="bg-border mb-px w-px" />
</Fragment> </Fragment>
))} ))}
</div> </div>

View file

@ -1,6 +1,6 @@
import { buttonVariants } from '@/laboratory/components/ui/button';
import { cn } from '@/laboratory/lib/utils';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '../../lib/utils';
import { buttonVariants } from './button';
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
@ -24,7 +24,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-neutral-1/50 fixed inset-0 z-50', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className, className,
)} )}
{...props} {...props}
@ -42,7 +42,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
'bg-neutral-3 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className, className,
)} )}
{...props} {...props}
@ -91,7 +91,7 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn('text-neutral-10 text-sm', className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
); );

View file

@ -1,17 +1,18 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-red-200 aria-invalid:border-red-500 transition-[color,box-shadow] overflow-hidden', 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{ {
variants: { variants: {
variant: { variant: {
default: 'border-transparent bg-neutral-11 text-neutral-2 [a&]:hover:bg-neutral-11/90', default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'border-transparent bg-neutral-2 text-neutral-11 [a&]:hover:bg-neutral-2/90', secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive: destructive:
'border-transparent bg-red-500 text-neutral-12 [a&]:hover:bg-red-900 focus-visible:ring-red-200', 'border-transparent text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
outline: 'text-neutral-11 [a&]:hover:bg-neutral-2 [a&]:hover:text-neutral-12', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { Separator } from '@/laboratory/components/ui/separator';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
import { Separator } from './separator';
const buttonGroupVariants = cva( const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:z-10 *:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", "flex w-fit items-stretch *:focus-visible:z-10 *:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
@ -48,7 +48,7 @@ function ButtonGroupText({
return ( return (
<Comp <Comp
className={cn( className={cn(
"bg-neutral-3 flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className, className,
)} )}
{...props} {...props}
@ -66,7 +66,7 @@ function ButtonGroupSeparator({
data-slot="button-group-separator" data-slot="button-group-separator"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-neutral-5 m-0! relative self-stretch data-[orientation=vertical]:h-auto', 'bg-input m-0! relative self-stretch data-[orientation=vertical]:h-auto',
className, className,
)} )}
{...props} {...props}

View file

@ -0,0 +1,56 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
outline:
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:text-accent-foreground hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -1,11 +1,11 @@
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../../lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
'bg-neutral-2 text-neutral-11 flex flex-col gap-6 rounded-xl border py-6 shadow-sm', 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className, className,
)} )}
{...props} {...props}
@ -40,7 +40,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn('text-neutral-10 text-sm', className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
); );

View file

@ -1,13 +1,13 @@
import { CheckIcon } from 'lucide-react'; import { CheckIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '../../lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
'border-neutral-5 data-[state=checked]:bg-neutral-11 data-[state=checked]:text-neutral-2 data-[state=checked]:border-neutral-11 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-red-200 aria-invalid:border-red-500 peer size-4 shrink-0 rounded-[4px] border shadow-sm outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', 'border-input bg-input/30 data-[state=checked]:text-primary-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer size-4 shrink-0 rounded-[4px] border shadow-sm outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
{...props} {...props}

View file

@ -2,21 +2,15 @@
import { Command as CommandPrimitive } from 'cmdk'; import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react'; import { SearchIcon } from 'lucide-react';
import { import { cn } from '../../lib/utils';
Dialog, import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog';
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/laboratory/components/ui/dialog';
import { cn } from '@/laboratory/lib/utils';
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) { function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return ( return (
<CommandPrimitive <CommandPrimitive
data-slot="command" data-slot="command"
className={cn( className={cn(
'bg-neutral-4 text-neutral-11 flex size-full flex-col overflow-hidden rounded-md', 'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md',
className, className,
)} )}
{...props} {...props}
@ -47,7 +41,7 @@ function CommandDialog({
className={cn('overflow-hidden p-0', className)} className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<Command className="[&_[cmdk-group-heading]]:text-neutral-10 **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"> <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
{children} {children}
</Command> </Command>
</DialogContent> </DialogContent>
@ -65,7 +59,7 @@ function CommandInput({
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
'placeholder:text-neutral-10 outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50', 'placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
@ -102,7 +96,7 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( className={cn(
'text-neutral-11 [&_[cmdk-group-heading]]:text-neutral-10 overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium', 'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className, className,
)} )}
{...props} {...props}
@ -117,7 +111,7 @@ function CommandSeparator({
return ( return (
<CommandPrimitive.Separator <CommandPrimitive.Separator
data-slot="command-separator" data-slot="command-separator"
className={cn('bg-neutral-5 -mx-1 h-px', className)} className={cn('bg-border -mx-1 h-px', className)}
{...props} {...props}
/> />
); );
@ -128,7 +122,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
className={cn( className={cn(
"data-[selected=true]:bg-neutral-2 data-[selected=true]:text-neutral-12 [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -140,7 +134,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
return ( return (
<span <span
data-slot="command-shortcut" data-slot="command-shortcut"
className={cn('text-neutral-10 ml-auto text-xs tracking-widest', className)} className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props} {...props}
/> />
); );

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cn } from '../../lib/utils';
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />; return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
@ -43,7 +44,7 @@ function ContextMenuSubTrigger({
data-slot="context-menu-sub-trigger" data-slot="context-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 data-[state=open]:bg-neutral-2 data-[state=open]:text-neutral-12 [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden data-inset:pl-8 flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden data-inset:pl-8 flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -62,7 +63,7 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( className={cn(
'bg-neutral-4 text-neutral-11 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className, className,
)} )}
{...props} {...props}
@ -74,17 +75,22 @@ function ContextMenuContent({
className, className,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
return ( return (
<ContextMenuPrimitive.Portal> <>
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Portal container={container}>
data-slot="context-menu-content" <ContextMenuPrimitive.Content
className={cn( data-slot="context-menu-content"
'bg-neutral-4 text-neutral-11 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md', className={cn(
className, 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
)} className,
{...props} )}
/> {...props}
</ContextMenuPrimitive.Portal> />
</ContextMenuPrimitive.Portal>
<div ref={setContainer} style={{ display: 'contents' }} />
</>
); );
} }
@ -103,7 +109,7 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 data-[variant=destructive]:*:[svg]:text-red-500! [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[variant=destructive]:text-red-500 data-[variant=destructive]:focus:bg-red-100 data-[variant=destructive]:focus:text-red-500 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -121,7 +127,7 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item" data-slot="context-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
checked={checked} checked={checked}
@ -146,7 +152,7 @@ function ContextMenuRadioItem({
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item" data-slot="context-menu-radio-item"
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -172,7 +178,7 @@ function ContextMenuLabel({
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
data-slot="context-menu-label" data-slot="context-menu-label"
data-inset={inset} data-inset={inset}
className={cn('text-neutral-11 data-inset:pl-8 px-2 py-1.5 text-sm font-medium', className)} className={cn('text-foreground data-inset:pl-8 px-2 py-1.5 text-sm font-medium', className)}
{...props} {...props}
/> />
); );
@ -185,7 +191,7 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-separator" data-slot="context-menu-separator"
className={cn('bg-neutral-5 -mx-1 my-1 h-px', className)} className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
); );
@ -195,7 +201,7 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span
return ( return (
<span <span
data-slot="context-menu-shortcut" data-slot="context-menu-shortcut"
className={cn('text-neutral-10 ml-auto text-xs tracking-widest', className)} className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props} {...props}
/> />
); );

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { XIcon } from 'lucide-react'; import { XIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '../../lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
@ -11,7 +12,14 @@ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive
} }
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; const [container, setContainer] = useState<HTMLDivElement | null>(null);
return (
<>
<DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
} }
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
@ -26,7 +34,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-neutral-1/50 fixed inset-0 z-50', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className, className,
)} )}
{...props} {...props}
@ -48,7 +56,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
'bg-neutral-3 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className, className,
)} )}
{...props} {...props}
@ -57,7 +65,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-neutral-2 focus:ring-ring data-[state=open]:bg-neutral-2 data-[state=open]:text-neutral-10 rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
@ -105,7 +113,7 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn('text-neutral-10 text-sm', className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
); );

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '../../lib/utils';
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
@ -23,18 +24,22 @@ function DropdownMenuContent({
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
return ( return (
<DropdownMenuPrimitive.Portal> <>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Portal container={container}>
data-slot="dropdown-menu-content" <DropdownMenuPrimitive.Content
sideOffset={sideOffset} data-slot="dropdown-menu-content"
className={cn( sideOffset={sideOffset}
'bg-neutral-4 text-neutral-11 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md', className={cn(
className, 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
)} className,
{...props} )}
/> {...props}
</DropdownMenuPrimitive.Portal> />
</DropdownMenuPrimitive.Portal>
<div ref={setContainer} style={{ display: 'contents' }} />
</>
); );
} }
@ -57,7 +62,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 data-[variant=destructive]:*:[svg]:text-red-500! [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[variant=destructive]:text-red-500 data-[variant=destructive]:focus:bg-red-100 data-[variant=destructive]:focus:text-red-500 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -75,7 +80,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
checked={checked} checked={checked}
@ -106,7 +111,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -145,7 +150,7 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn('bg-neutral-5 -mx-1 my-1 h-px', className)} className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
); );
@ -155,7 +160,7 @@ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'spa
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn('text-neutral-10 ml-auto text-xs tracking-widest', className)} className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props} {...props}
/> />
); );
@ -178,7 +183,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 data-[state=open]:bg-neutral-2 data-[state=open]:text-neutral-12 [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden data-inset:pl-8 flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden data-inset:pl-8 flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -197,7 +202,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
'bg-neutral-4 text-neutral-11 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
className, className,
)} )}
{...props} {...props}

View file

@ -1,5 +1,5 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../../lib/utils';
function Empty({ className, ...props }: React.ComponentProps<'div'>) { function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
@ -30,7 +30,7 @@ const emptyMediaVariants = cva(
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: 'bg-transparent',
icon: "bg-neutral-3 text-neutral-11 flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -69,7 +69,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
<div <div
data-slot="empty-description" data-slot="empty-description"
className={cn( className={cn(
'text-neutral-10 [&>a:hover]:text-neutral-11 text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', 'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className, className,
)} )}
{...props} {...props}

View file

@ -1,8 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { Label } from '@/laboratory/components/ui/label'; import { cn } from '../../lib/utils';
import { Separator } from '@/laboratory/components/ui/separator'; import { Label } from './label';
import { cn } from '@/laboratory/lib/utils'; import { Separator } from './separator';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return ( return (
@ -51,7 +51,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
); );
} }
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-red-500', { const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
variants: { variants: {
orientation: { orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
@ -105,7 +105,7 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
className={cn( className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50', 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4', 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-[>[data-state=checked]]:bg-neutral-11/5 has-[>[data-state=checked]]:border-neutral-11', 'has-[>[data-state=checked]]:border-primary has-[>[data-state=checked]]:bg-primary/10',
className, className,
)} )}
{...props} {...props}
@ -131,9 +131,9 @@ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
'text-neutral-10 text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance', 'text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5', 'nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-neutral-11 [&>a]:underline [&>a]:underline-offset-4', '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className, className,
)} )}
{...props} {...props}
@ -161,7 +161,7 @@ function FieldSeparator({
<Separator className="absolute inset-0 top-1/2" /> <Separator className="absolute inset-0 top-1/2" />
{children && ( {children && (
<span <span
className="bg-neutral-3 text-neutral-10 relative mx-auto block w-fit px-2" className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content" data-slot="field-separator-content"
> >
{children} {children}
@ -209,7 +209,7 @@ function FieldError({
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn('text-sm font-normal text-red-500', className)} className={cn('text-destructive text-sm font-normal', className)}
{...props} {...props}
> >
{content} {content}

View file

@ -1,8 +1,8 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { Button } from '@/laboratory/components/ui/button'; import { cn } from '../../lib/utils';
import { Input } from '@/laboratory/components/ui/input'; import { Button } from './button';
import { Textarea } from '@/laboratory/components/ui/textarea'; import { Input } from './input';
import { cn } from '@/laboratory/lib/utils'; import { Textarea } from './textarea';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
@ -10,7 +10,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
'group/input-group border-neutral-4 dark:bg-neutral-4/30 relative flex w-full items-center rounded-md border shadow-sm outline-none transition-[color,box-shadow]', 'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-sm outline-none transition-[color,box-shadow]',
'h-9 min-w-0 has-[>textarea]:h-auto', 'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment. // Variants based on alignment.
@ -23,7 +23,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]', 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state. // Error state.
'has-[[data-slot][aria-invalid=true]]:border-red-500 has-[[data-slot][aria-invalid=true]]:ring-red-500/20 dark:has-[[data-slot][aria-invalid=true]]:ring-red-500/40', 'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className, className,
)} )}
@ -33,7 +33,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
"text-neutral-10 flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{ {
variants: { variants: {
align: { align: {
@ -57,7 +57,6 @@ function InputGroupAddon({
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) { }: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return ( return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div <div
role="group" role="group"
data-slot="input-group-addon" data-slot="input-group-addon"
@ -111,7 +110,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return ( return (
<span <span
className={cn( className={cn(
"text-neutral-10 flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", "text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className, className,
)} )}
{...props} {...props}

View file

@ -0,0 +1,19 @@
import { cn } from '../../lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input/30 border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-sm outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -1,5 +1,5 @@
import { cn } from '@/laboratory/lib/utils';
import * as LabelPrimitive from '@radix-ui/react-label'; import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '../../lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (

View file

@ -1,6 +1,6 @@
import { GripVerticalIcon } from 'lucide-react'; import { GripVerticalIcon } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels'; import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../../lib/utils';
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
@ -30,13 +30,13 @@ function ResizableHandle({
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
'bg-neutral-5 focus-visible:ring-ring focus-visible:outline-hidden relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90', 'bg-border focus-visible:ring-ring focus-visible:outline-hidden relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className, className,
)} )}
{...props} {...props}
> >
{withHandle && ( {withHandle && (
<div className="bg-neutral-5 rounded-xs z-10 flex h-4 w-3 items-center justify-center border"> <div className="bg-border rounded-xs z-10 flex h-4 w-3 items-center justify-center border">
<GripVerticalIcon className="size-2.5" /> <GripVerticalIcon className="size-2.5" />
</div> </div>
)} )}

View file

@ -1,5 +1,5 @@
import { cn } from '@/laboratory/lib/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../../lib/utils';
function ScrollArea({ function ScrollArea({
className, className,
@ -43,7 +43,7 @@ function ScrollBar({
> >
<ScrollAreaPrimitive.ScrollAreaThumb <ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb" data-slot="scroll-area-thumb"
className="bg-neutral-5 relative flex-1 rounded-full" className="bg-border relative flex-1 rounded-full"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
); );

View file

@ -1,6 +1,7 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils'; import { useLaboratory } from '@/components/laboratory/context';
import * as SelectPrimitive from '@radix-ui/react-select'; import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '../../lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />; return <SelectPrimitive.Root data-slot="select" {...props} />;
@ -27,7 +28,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-neutral-5 data-placeholder:text-neutral-10 [&_svg:not([class*='text-'])]:text-neutral-10 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-red-200 aria-invalid:border-red-500 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-input/30 hover:bg-input/50 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border px-3 py-2 text-sm shadow-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -47,12 +48,14 @@ function SelectContent({
align = 'center', align = 'center',
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
const { container } = useLaboratory();
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal container={container}>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
'bg-neutral-4 text-neutral-11 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
@ -81,7 +84,7 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn('text-neutral-10 px-2 py-1.5 text-xs', className)} className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props} {...props}
/> />
); );
@ -96,7 +99,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-neutral-2 focus:text-neutral-12 [&_svg:not([class*='text-'])]:text-neutral-10 outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-disabled:pointer-events-none data-disabled:opacity-50 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-disabled:pointer-events-none data-disabled:opacity-50 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
@ -118,7 +121,7 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn('bg-neutral-5 pointer-events-none -mx-1 my-1 h-px', className)} className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
); );

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { cn } from '@/laboratory/lib/utils';
import * as SeparatorPrimitive from '@radix-ui/react-separator'; import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../lib/utils';
function Separator({ function Separator({
className, className,
@ -15,7 +15,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-neutral-5 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px', 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
className, className,
)} )}
{...props} {...props}

View file

@ -6,14 +6,11 @@ import {
TriangleAlertIcon, TriangleAlertIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Toaster as Sonner, type ToasterProps } from 'sonner'; import { Toaster as Sonner, type ToasterProps } from 'sonner';
import { useTheme } from '@/components/theme/theme-provider';
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps['theme']} theme={props.theme}
className="group" className="group"
icons={{ icons={{
success: <CircleCheckIcon className="size-4" />, success: <CircleCheckIcon className="size-4" />,
@ -24,9 +21,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
}} }}
style={ style={
{ {
'--normal-bg': 'var(--neutral-2)', '--normal-bg': 'var(--popover)',
'--normal-text': 'var(--neutral-11)', '--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--neutral-4)', '--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)', '--border-radius': 'var(--radius)',
} as React.CSSProperties } as React.CSSProperties
} }

View file

@ -10,8 +10,6 @@ import {
useState, useState,
} from 'react'; } from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { useComposedRefs } from '@/laboratory/lib/compose-refs';
import { cn } from '@/laboratory/lib/utils';
import { import {
closestCenter, closestCenter,
closestCorners, closestCorners,
@ -49,6 +47,8 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { useComposedRefs } from '../../lib/compose-refs';
import { cn } from '../../lib/utils';
const orientationConfig = { const orientationConfig = {
vertical: { vertical: {

View file

@ -1,5 +1,5 @@
import { Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils'; import { cn } from '../../lib/utils';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) { function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return ( return (

View file

@ -0,0 +1,24 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 data-[state=unchecked]:bg-input/80 data-[state=checked]:bg-primary peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-sm outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background data-[state=unchecked]:bg-foreground data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View file

@ -1,13 +1,9 @@
import { cn } from '@/laboratory/lib/utils';
import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '../../lib/utils';
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col', className)} {...props} />
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
); );
} }
@ -16,7 +12,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
'bg-neutral-2 dark:bg-neutral-3 text-neutral-10 inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className, className,
)} )}
{...props} {...props}
@ -29,7 +25,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:text-neutral-11 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring data-[state=active]:border-neutral-4 data-[state=active]:bg-neutral-4/30 text-neutral-8 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", "data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring data-[state=active]:border-input data-[state=active]:bg-input/30 text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}

View file

@ -0,0 +1,16 @@
import { cn } from '../../lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -1,15 +1,15 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import * as TogglePrimitive from '@radix-ui/react-toggle'; import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cn } from '../../lib/utils';
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-neutral-3 hover:text-neutral-10 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-neutral-2 data-[state=on]:text-neutral-12 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-red-200 aria-invalid:border-red-500 whitespace-nowrap", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: 'bg-transparent',
outline: outline:
'border border-neutral-5 bg-transparent shadow-sm hover:bg-neutral-2 hover:text-neutral-12', 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
}, },
size: { size: {
default: 'h-9 px-2 min-w-9', default: 'h-9 px-2 min-w-9',

View file

@ -0,0 +1,59 @@
import { useState } from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
return (
<>
<TooltipPrimitive.Portal container={container}>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,161 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
}
:host,
.hive-laboratory {
display: block;
width: 100%;
height: 100%;
}
.hive-laboratory {
--color-neutral-1: 0 0% 99%;
--color-neutral-2: 180 9% 97%;
--color-neutral-3: 210 7% 95%;
--color-neutral-4: 200 6% 92%;
--color-neutral-5: 210 6% 89%;
--color-neutral-6: 204 5% 79%;
--color-neutral-7: 197 4% 67%;
--color-neutral-8: 196 4% 51%;
--color-neutral-9: 193 9% 32%;
--color-neutral-10: 188 15% 21%;
--color-neutral-11: 188 19% 15%;
--color-neutral-12: 175 23% 10%;
--color-accent: 206 96% 35%;
--color-ring: 216 58% 49%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-2));
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
--muted: var(--hive-laboratory-muted, 24 9.8% 10%);
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
--popover-foreground: var(--hive-laboratory-popover-foreground, var(--color-neutral-11));
--card: var(--hive-laboratory-card, var(--color-neutral-1));
--card-foreground: var(--hive-laboratory-card-foreground, var(--color-neutral-11));
--border: var(--hive-laboratory-border, var(--color-neutral-5));
--input: var(--hive-laboratory-input, var(--color-neutral-5));
--primary: var(--hive-laboratory-primary, var(--color-accent));
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
}
.hive-laboratory.dark {
--color-neutral-1: 210 21% 5%;
--color-neutral-2: 210 16% 7%;
--color-neutral-3: 214 14% 10%;
--color-neutral-4: 210 13% 13%;
--color-neutral-5: 210 12% 16%;
--color-neutral-6: 210 11% 21%;
--color-neutral-7: 210 12% 27%;
--color-neutral-8: 210 11% 34%;
--color-neutral-9: 210 10% 43%;
--color-neutral-10: 213 9% 58%;
--color-neutral-11: 210 11% 75%;
--color-neutral-12: 204 14% 93%;
--color-accent: 48 100% 83%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-1));
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
--muted: var(--hive-laboratory-muted, var(--color-neutral-3));
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
--popover-foreground: var(--hive-laboratory-popover-foreground, var(--color-neutral-11));
--card: var(--hive-laboratory-card, var(--color-neutral-2));
--card-foreground: var(--hive-laboratory-card-foreground, var(--color-neutral-11));
--border: var(--hive-laboratory-border, var(--color-neutral-5));
--input: var(--hive-laboratory-input, var(--color-neutral-5));
--primary: var(--hive-laboratory-primary, var(--color-accent));
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
}
@layer components {
* {
--tw-border-style: solid;
--tw-translate-x: 0;
--tw-translate-y: 0;
@apply outline-ring/50;
}
*,
:after,
:before,
::backdrop {
border: 0 solid hsl(var(--border));
}
}
@layer color {
.hive-laboratory {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,77 @@
import ReactDOM from 'react-dom/client';
import { Laboratory } from './components/laboratory/laboratory';
export * from './components/laboratory/laboratory';
export * from './components/laboratory/context';
export * from './components/laboratory/editor';
export * from './components/ui/dialog';
export * from './components/ui/tabs';
export * from './lib/endpoint';
export * from './lib/collections';
export * from './lib/env';
export * from './lib/history';
export * from './lib/operations';
export * from './lib/preflight';
export * from './lib/settings';
export * from './lib/tabs';
export * from './lib/tests';
export * from './lib/plugins';
export const renderLaboratory = (el: HTMLElement) => {
const prefix = 'hive-laboratory';
const getLocalStorage = (key: string) => {
const value = localStorage.getItem(`${prefix}:${key}`);
return value ? JSON.parse(value) : null;
};
const setLocalStorage = (key: string, value: unknown) => {
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
};
if (!el) {
throw new Error('Laboratory element not found');
}
return ReactDOM.createRoot(el).render(
<Laboratory
theme="dark"
defaultEndpoint={getLocalStorage('endpoint') ?? null}
onEndpointChange={endpoint => {
setLocalStorage('endpoint', endpoint ?? '');
}}
defaultCollections={getLocalStorage('collections') ?? []}
onCollectionsChange={collections => {
setLocalStorage('collections', collections);
}}
defaultTabs={getLocalStorage('tabs') ?? []}
onTabsChange={tabs => {
setLocalStorage('tabs', tabs);
}}
defaultOperations={getLocalStorage('operations') ?? []}
onOperationsChange={operations => {
setLocalStorage('operations', operations);
}}
defaultActiveTabId={getLocalStorage('activeTabId') ?? null}
onActiveTabIdChange={activeTabId => {
setLocalStorage('activeTabId', activeTabId ?? '');
}}
defaultPreflight={getLocalStorage('preflight') ?? null}
onPreflightChange={preflight => {
setLocalStorage('preflight', preflight ?? '');
}}
defaultEnv={getLocalStorage('env') ?? null}
onEnvChange={env => {
setLocalStorage('env', env ?? '');
}}
defaultSettings={getLocalStorage('settings') ?? null}
onSettingsChange={settings => {
setLocalStorage('settings', settings ?? '');
}}
defaultHistory={getLocalStorage('history') ?? []}
onHistoryChange={history => {
setLocalStorage('history', history);
}}
/>,
);
};

View file

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { LaboratoryOperation } from '@/laboratory/lib/operations'; import type { LaboratoryOperation } from './operations';
import type { LaboratoryTabsActions, LaboratoryTabsState } from '@/laboratory/lib/tabs'; import type { LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
export interface LaboratoryCollectionOperation extends LaboratoryOperation { export interface LaboratoryCollectionOperation extends LaboratoryOperation {
id: string; id: string;

View file

@ -25,7 +25,6 @@ export const useEndpoint = (props: {
onEndpointChange?: (endpoint: string | null) => void; onEndpointChange?: (endpoint: string | null) => void;
defaultSchemaIntrospection?: IntrospectionQuery | null; defaultSchemaIntrospection?: IntrospectionQuery | null;
}): LaboratoryEndpointState & LaboratoryEndpointActions => { }): LaboratoryEndpointState & LaboratoryEndpointActions => {
// eslint-disable-next-line react/hook-use-state
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null); const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null); const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);

View file

@ -16,7 +16,6 @@ export const useEnv = (props: {
defaultEnv?: LaboratoryEnv | null; defaultEnv?: LaboratoryEnv | null;
onEnvChange?: (env: LaboratoryEnv | null) => void; onEnvChange?: (env: LaboratoryEnv | null) => void;
}): LaboratoryEnvState & LaboratoryEnvActions => { }): LaboratoryEnvState & LaboratoryEnvActions => {
// eslint-disable-next-line react/hook-use-state
const [env, _setEnv] = useState<LaboratoryEnv>(props.defaultEnv ?? { variables: {} }); const [env, _setEnv] = useState<LaboratoryEnv>(props.defaultEnv ?? { variables: {} });
const setEnv = useCallback( const setEnv = useCallback(

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { LaboratoryOperation } from '@/laboratory/lib/operations'; import type { LaboratoryOperation } from './operations';
import type { LaboratoryPreflightLog } from '@/laboratory/lib/preflight'; import type { LaboratoryPreflightLog } from './preflight';
export interface LaboratoryHistoryRequest { export interface LaboratoryHistoryRequest {
id: string; id: string;

View file

@ -2,16 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { GraphQLSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql';
import { createClient } from 'graphql-ws'; import { createClient } from 'graphql-ws';
import { decompressFromEncodedURIComponent } from 'lz-string'; import { decompressFromEncodedURIComponent } from 'lz-string';
import { import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
LaboratoryPermission,
LaboratoryPermissions,
} from '@/laboratory/components/laboratory/context';
import type { import type {
LaboratoryCollectionOperation, LaboratoryCollectionOperation,
LaboratoryCollectionsActions, LaboratoryCollectionsActions,
LaboratoryCollectionsState, LaboratoryCollectionsState,
} from '@/laboratory/lib/collections'; } from './collections';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env'; import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from './env';
import { import {
addArgToField, addArgToField,
addPathToQuery, addPathToQuery,
@ -19,22 +16,11 @@ import {
getOperationName, getOperationName,
handleTemplate, handleTemplate,
removeArgFromField, removeArgFromField,
} from '@/laboratory/lib/operations.utils'; } from './operations.utils';
import { import { LaboratoryPlugin, LaboratoryPluginsActions, LaboratoryPluginsState } from './plugins';
LaboratoryPlugin, import type { LaboratoryPreflightActions, LaboratoryPreflightState } from './preflight';
LaboratoryPluginsActions, import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
LaboratoryPluginsState, import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
} from '@/laboratory/lib/plugins';
import type {
LaboratoryPreflightActions,
LaboratoryPreflightState,
} from '@/laboratory/lib/preflight';
import type { LaboratorySettingsActions, LaboratorySettingsState } from '@/laboratory/lib/settings';
import type {
LaboratoryTabOperation,
LaboratoryTabsActions,
LaboratoryTabsState,
} from '@/laboratory/lib/tabs';
export interface LaboratoryOperation { export interface LaboratoryOperation {
id: string; id: string;
@ -100,7 +86,6 @@ export const useOperations = (
pluginsApi?: LaboratoryPluginsState & LaboratoryPluginsActions; pluginsApi?: LaboratoryPluginsState & LaboratoryPluginsActions;
} & LaboratoryOperationsCallbacks, } & LaboratoryOperationsCallbacks,
): LaboratoryOperationsState & LaboratoryOperationsActions => { ): LaboratoryOperationsState & LaboratoryOperationsActions => {
// eslint-disable-next-line react/hook-use-state
const [operations, _setOperations] = useState<LaboratoryOperation[]>( const [operations, _setOperations] = useState<LaboratoryOperation[]>(
props.defaultOperations ?? [], props.defaultOperations ?? [],
); );

View file

@ -21,7 +21,7 @@ import {
} from 'graphql'; } from 'graphql';
import type { Maybe } from 'graphql/jsutils/Maybe'; import type { Maybe } from 'graphql/jsutils/Maybe';
import { get } from 'lodash'; import { get } from 'lodash';
import type { LaboratoryOperation } from '@/laboratory/lib/operations'; import type { LaboratoryOperation } from './operations';
export function healQuery(query: string) { export function healQuery(query: string) {
return query.replace(/\{(\s+)?\}/g, ''); return query.replace(/\{(\s+)?\}/g, '');

View file

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { LaboratoryContextProps } from '@/laboratory/components/laboratory/context'; import type { LaboratoryContextProps } from '../components/laboratory/context';
import { LaboratoryTabCustom } from '@/laboratory/lib/tabs'; import { LaboratoryTabCustom } from './tabs';
export interface LaboratoryPluginTab<State = Record<string, unknown>> { export interface LaboratoryPluginTab<State = Record<string, unknown>> {
type: string; type: string;
@ -56,7 +56,6 @@ export const usePlugins = (props: {
defaultPluginsState?: Record<string, any>; defaultPluginsState?: Record<string, any>;
onPluginsStateChange?: (state: Record<string, any>) => void; onPluginsStateChange?: (state: Record<string, any>) => void;
}): LaboratoryPluginsState & LaboratoryPluginsActions => { }): LaboratoryPluginsState & LaboratoryPluginsActions => {
// eslint-disable-next-line react/hook-use-state
const [pluginsState, _setPluginsState] = useState<Record<string, any>>({ const [pluginsState, _setPluginsState] = useState<Record<string, any>>({
...props.plugins?.reduce( ...props.plugins?.reduce(
(acc, plugin) => { (acc, plugin) => {

View file

@ -1,14 +1,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import cryptoJsSource from 'crypto-js/crypto-js.js?raw'; import cryptoJsSource from 'crypto-js/crypto-js.js?raw';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env'; import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from './env';
import { LaboratoryPlugin } from '@/laboratory/lib/plugins'; import { LaboratoryPlugin } from './plugins';
export interface LaboratoryPreflightPromptField {
title?: string;
defaultValue?: string;
description?: string;
placeholder?: string;
}
export interface LaboratoryPreflightLog { export interface LaboratoryPreflightLog {
level: 'log' | 'warn' | 'error' | 'info' | 'system'; level: 'log' | 'warn' | 'error' | 'info' | 'system';
@ -48,15 +41,7 @@ export const usePreflight = (props: {
defaultPreflight?: LaboratoryPreflight | null; defaultPreflight?: LaboratoryPreflight | null;
onPreflightChange?: (preflight: LaboratoryPreflight | null) => void; onPreflightChange?: (preflight: LaboratoryPreflight | null) => void;
envApi: LaboratoryEnvState & LaboratoryEnvActions; envApi: LaboratoryEnvState & LaboratoryEnvActions;
openPreflightPromptModal?: (props: {
title?: string;
description?: string;
placeholder?: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => void;
}): LaboratoryPreflightState & LaboratoryPreflightActions => { }): LaboratoryPreflightState & LaboratoryPreflightActions => {
// eslint-disable-next-line react/hook-use-state
const [preflight, _setPreflight] = useState<LaboratoryPreflight | null>( const [preflight, _setPreflight] = useState<LaboratoryPreflight | null>(
props.defaultPreflight ?? null, props.defaultPreflight ?? null,
); );
@ -78,19 +63,7 @@ export const usePreflight = (props: {
return runIsolatedLabScript( return runIsolatedLabScript(
preflight.script, preflight.script,
props.envApi?.env ?? { variables: {} }, props.envApi?.env ?? { variables: {} },
(title, defaultValue, options) => { undefined,
return new Promise(resolve => {
props.openPreflightPromptModal?.({
title,
description: options?.description,
placeholder: options?.placeholder,
defaultValue,
onSubmit: value => {
resolve(value);
},
});
});
},
plugins, plugins,
pluginsState, pluginsState,
); );
@ -100,7 +73,10 @@ export const usePreflight = (props: {
const setLastTestResult = useCallback( const setLastTestResult = useCallback(
(result: LaboratoryPreflightResult | null) => { (result: LaboratoryPreflightResult | null) => {
_setPreflight({ ...(preflight ?? { script: '', enabled: true }), lastTestResult: result }); _setPreflight({
...(preflight ?? { script: '', enabled: true }),
lastTestResult: result,
});
props.onPreflightChange?.({ props.onPreflightChange?.({
...(preflight ?? { script: '', enabled: true }), ...(preflight ?? { script: '', enabled: true }),
lastTestResult: result, lastTestResult: result,
@ -120,11 +96,7 @@ export const usePreflight = (props: {
export async function runIsolatedLabScript( export async function runIsolatedLabScript(
script: string, script: string,
env: LaboratoryEnv, env: LaboratoryEnv,
prompt?: ( prompt?: (placeholder: string, defaultValue: string) => Promise<string | null>,
title: string,
defaultValue: string,
options?: { placeholder?: string; description?: string },
) => Promise<string | null>,
plugins: LaboratoryPlugin[] = [], plugins: LaboratoryPlugin[] = [],
pluginsState: Record<string, any> = {}, pluginsState: Record<string, any> = {},
): Promise<LaboratoryPreflightResult> { ): Promise<LaboratoryPreflightResult> {
@ -184,10 +156,10 @@ export async function runIsolatedLabScript(
request: { request: {
headers: new Headers() headers: new Headers()
}, },
prompt: (title, defaultValue, options) => { prompt: (placeholder, defaultValue) => {
return new Promise((resolve) => { return new Promise((resolve) => {
promptResolve = resolve; promptResolve = resolve;
self.postMessage({ type: 'prompt', title, defaultValue, options: options ?? {} }); self.postMessage({ type: 'prompt', placeholder, defaultValue });
}); });
}, },
plugins: { plugins: {
@ -256,13 +228,29 @@ export async function runIsolatedLabScript(
} }
} else if (data.type === 'log') { } else if (data.type === 'log') {
if (data.level === 'log') { if (data.level === 'log') {
logs.push({ level: 'log', message: data.message, createdAt: new Date().toISOString() }); logs.push({
level: 'log',
message: data.message,
createdAt: new Date().toISOString(),
});
} else if (data.level === 'warn') { } else if (data.level === 'warn') {
logs.push({ level: 'warn', message: data.message, createdAt: new Date().toISOString() }); logs.push({
level: 'warn',
message: data.message,
createdAt: new Date().toISOString(),
});
} else if (data.level === 'error') { } else if (data.level === 'error') {
logs.push({ level: 'error', message: data.message, createdAt: new Date().toISOString() }); logs.push({
level: 'error',
message: data.message,
createdAt: new Date().toISOString(),
});
} else if (data.level === 'info') { } else if (data.level === 'info') {
logs.push({ level: 'info', message: data.message, createdAt: new Date().toISOString() }); logs.push({
level: 'info',
message: data.message,
createdAt: new Date().toISOString(),
});
} }
} else if (data.type === 'header') { } else if (data.type === 'header') {
headers[data.name] = data.value; headers[data.name] = data.value;
@ -273,7 +261,7 @@ export async function runIsolatedLabScript(
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
} else if (data.type === 'prompt') { } else if (data.type === 'prompt') {
void prompt?.(data.title, data.defaultValue, data.options).then(value => { void prompt?.(data.placeholder, data.defaultValue).then(value => {
worker.postMessage({ type: 'prompt:result', value }); worker.postMessage({ type: 'prompt:result', value });
}); });
} }

View file

@ -18,7 +18,6 @@ export const useSettings = (props: {
defaultSettings?: LaboratorySettings | null; defaultSettings?: LaboratorySettings | null;
onSettingsChange?: (settings: LaboratorySettings | null) => void; onSettingsChange?: (settings: LaboratorySettings | null) => void;
}): LaboratorySettingsState & LaboratorySettingsActions => { }): LaboratorySettingsState & LaboratorySettingsActions => {
// eslint-disable-next-line react/hook-use-state
const [settings, _setSettings] = useState<LaboratorySettings>( const [settings, _setSettings] = useState<LaboratorySettings>(
props.defaultSettings ?? { props.defaultSettings ?? {
fetch: { fetch: {

View file

@ -1,9 +1,9 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { LaboratoryEnv } from '@/laboratory/lib/env'; import type { LaboratoryEnv } from './env';
import type { LaboratoryHistoryRequest } from '@/laboratory/lib/history'; import type { LaboratoryHistoryRequest } from './history';
import type { LaboratoryOperation } from '@/laboratory/lib/operations'; import type { LaboratoryOperation } from './operations';
import type { LaboratoryPreflight } from '@/laboratory/lib/preflight'; import type { LaboratoryPreflight } from './preflight';
import type { LaboratoryTest } from '@/laboratory/lib/tests'; import type { LaboratoryTest } from './tests';
export interface LaboratoryTabOperation { export interface LaboratoryTabOperation {
id: string; id: string;
@ -88,10 +88,8 @@ export const useTabs = (props: {
onTabsChange?: (tabs: LaboratoryTab[]) => void; onTabsChange?: (tabs: LaboratoryTab[]) => void;
onActiveTabIdChange?: (tabId: string | null) => void; onActiveTabIdChange?: (tabId: string | null) => void;
}): LaboratoryTabsState & LaboratoryTabsActions => { }): LaboratoryTabsState & LaboratoryTabsActions => {
// eslint-disable-next-line react/hook-use-state
const [tabs, _setTabs] = useState<LaboratoryTab[]>(props.defaultTabs ?? []); const [tabs, _setTabs] = useState<LaboratoryTab[]>(props.defaultTabs ?? []);
// eslint-disable-next-line react/hook-use-state
const [activeTab, _setActiveTab] = useState<LaboratoryTab | null>( const [activeTab, _setActiveTab] = useState<LaboratoryTab | null>(
props.defaultTabs?.find(t => t.id === props.defaultActiveTabId) ?? props.defaultTabs?.find(t => t.id === props.defaultActiveTabId) ??
props.defaultTabs?.[0] ?? props.defaultTabs?.[0] ??

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { LaboratoryOperation } from '@/laboratory/lib/operations'; import type { LaboratoryOperation } from './operations';
export interface LaboratoryTestTaskBase { export interface LaboratoryTestTaskBase {
id: string; id: string;

View file

@ -0,0 +1,53 @@
import ReactDOM from 'react-dom/client';
import { Laboratory } from './components/laboratory/laboratory';
const getLocalStorage = (key: string) => {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : null;
};
const setLocalStorage = (key: string, value: unknown) => {
localStorage.setItem(key, JSON.stringify(value));
};
ReactDOM.createRoot(document.getElementById('root')!).render(
<Laboratory
theme="dark"
defaultEndpoint={getLocalStorage('endpoint') ?? null}
onEndpointChange={endpoint => {
setLocalStorage('endpoint', endpoint ?? '');
}}
defaultCollections={getLocalStorage('collections') ?? []}
onCollectionsChange={collections => {
setLocalStorage('collections', collections);
}}
defaultTabs={getLocalStorage('tabs') ?? []}
onTabsChange={tabs => {
setLocalStorage('tabs', tabs);
}}
defaultOperations={getLocalStorage('operations') ?? []}
onOperationsChange={operations => {
setLocalStorage('operations', operations);
}}
defaultActiveTabId={getLocalStorage('activeTabId') ?? null}
onActiveTabIdChange={activeTabId => {
setLocalStorage('activeTabId', activeTabId ?? '');
}}
defaultPreflight={getLocalStorage('preflight') ?? null}
onPreflightChange={preflight => {
setLocalStorage('preflight', preflight ?? '');
}}
defaultEnv={getLocalStorage('env') ?? null}
onEnvChange={env => {
setLocalStorage('env', env ?? '');
}}
defaultSettings={getLocalStorage('settings') ?? null}
onSettingsChange={settings => {
setLocalStorage('settings', settings ?? '');
}}
defaultHistory={getLocalStorage('history') ?? []}
onHistoryChange={history => {
setLocalStorage('history', history);
}}
/>,
);

View file

@ -0,0 +1,109 @@
import { GlobeIcon } from 'lucide-react';
import { Editor } from '../components/laboratory/editor';
import { LaboratoryPlugin } from '../lib/plugins';
export const TargetEnvPlugin = (props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
}) => {
const targetId = `${props.organizationSlug}/${props.projectSlug}/${props.targetSlug}`;
return {
id: 'targetEnv',
name: 'Target Environment',
description: 'Environment variables for the target',
preflight: {
lab: {
definition: `
targetEnvironment: {
set: (key: string, value: string) => void;
get: (key: string) => string;
delete: (key: string) => void;
};
`,
props: {
targetId,
},
object: (props, state, setState) => {
return {
targetEnvironment: {
set: (key: string, value: string) => {
setState({
...state,
[props.targetId]: {
...state[props.targetId],
[key]: value,
},
});
},
get: (key: string) => {
return state[props.targetId]?.[key];
},
delete: (key: string) => {
const newState = JSON.parse(JSON.stringify(state));
delete newState[props.targetId][key];
setState(newState);
},
},
};
},
},
},
commands: [
{
name: 'Open Target Environment Variables',
icon: <GlobeIcon />,
onClick: laboratory => {
const tab =
laboratory.tabs.find(t => t.type === 'target-env') ??
laboratory.addTab({
type: 'target-env',
data: {},
});
laboratory.setActiveTab(tab);
},
},
],
tabs: [
{
type: 'target-env',
name: 'Target Environment Variables',
icon: <GlobeIcon className="size-4 text-orange-400" />,
component: (_tab, _laboratory, state, setState) => {
return (
<Editor
defaultValue={Object.entries(state?.[targetId] ?? {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')}
onChange={value => {
setState({
...state,
[targetId]: Object.fromEntries(
value
?.split('\n')
.filter(line => line.trim() && !line.trim().startsWith('#'))
.map(line => {
const parts = line.split(/=(.*)/s);
return [parts[0].trim(), (parts[1] ?? '').trim()];
}) ?? [],
),
});
}}
language="dotenv"
options={{
scrollbar: {
horizontal: 'hidden',
},
}}
/>
);
},
},
],
} satisfies LaboratoryPlugin<Record<string, Record<string, string>>>;
};

View file

@ -0,0 +1,32 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View file

@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vite.lib.config.ts", "vite.umd.config.ts"]
}

View file

@ -0,0 +1,30 @@
import path from 'path';
import { defineConfig } from 'vite';
import monacoEditor from 'vite-plugin-monaco-editor';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig(() => {
return {
plugins: [
react(),
tailwindcss(),
// @ts-expect-error temp
monacoEditor.default({
languageWorkers: ['json', 'typescript', 'editorWorkerService'],
customWorkers: [
{
label: 'graphql',
entry: 'monaco-graphql/dist/graphql.worker',
},
],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
};
});

View file

@ -0,0 +1,83 @@
import path from 'path';
import dts from 'unplugin-dts/vite';
import { defineConfig } from 'vite';
import monacoEditor from 'vite-plugin-monaco-editor';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
const externals = [
'@tanstack/react-form',
'date-fns',
'graphql-ws',
'lucide-react',
'lz-string',
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'tslib',
'zod',
];
export default defineConfig({
plugins: [
react(),
tailwindcss(),
// @ts-expect-error temporary package typing mismatch
monacoEditor.default({
languageWorkers: ['json', 'typescript', 'editorWorkerService'],
customWorkers: [
{
label: 'graphql',
entry: 'monaco-graphql/dist/graphql.worker',
},
],
}),
dts({
include: ['src/index.tsx', 'src/lib/**/*.ts', 'src/components/**/*.tsx'],
exclude: ['src/main.tsx'],
insertTypesEntry: true,
staticImport: true,
outDirs: ['dist'],
tsconfigPath: './tsconfig.app.json',
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
copyPublicDir: false,
cssCodeSplit: false,
commonjsOptions: {
esmExternals: true,
},
lib: {
entry: path.resolve(__dirname, './src/index.tsx'),
name: 'HiveLaboratory',
formats: ['es', 'cjs'],
fileName: format => `hive-laboratory.${format}.js`,
},
rollupOptions: {
external: externals,
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-dom/client': 'ReactDOM',
'@tanstack/react-form': 'TanStackReactForm',
'date-fns': 'dateFns',
'graphql-ws': 'graphqlWs',
'lucide-react': 'LucideReact',
'lz-string': 'LZString',
'react/jsx-runtime': 'ReactJSXRuntime',
'react/jsx-dev-runtime': 'ReactJSXRuntime',
tslib: 'tslib',
zod: 'Zod',
},
},
},
},
});

View file

@ -0,0 +1,49 @@
import path from 'path';
import { defineConfig } from 'vite';
import monacoEditor from 'vite-plugin-monaco-editor';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'global.process.env.NODE_ENV': JSON.stringify('production'),
},
plugins: [
react(),
tailwindcss(),
// @ts-expect-error temporary package typing mismatch
monacoEditor.default({
languageWorkers: ['json', 'typescript', 'editorWorkerService'],
customWorkers: [
{
label: 'graphql',
entry: 'monaco-graphql/dist/graphql.worker',
},
],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
copyPublicDir: false,
emptyOutDir: false,
cssCodeSplit: false,
rollupOptions: {
output: {
// UMD consumers can execute this in plain browser contexts without Node globals.
intro:
"var process = typeof globalThis !== 'undefined' && globalThis.process ? globalThis.process : { env: { NODE_ENV: 'production' } };",
},
},
lib: {
entry: path.resolve(__dirname, './src/index.tsx'),
name: 'HiveLaboratory',
formats: ['umd'],
fileName: () => 'hive-laboratory.umd.js',
},
},
});

View file

@ -0,0 +1 @@
src/laboratory.ts

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
{
"name": "@graphql-hive/render-laboratory",
"version": "0.0.1",
"type": "module",
"description": "",
"repository": {
"type": "git",
"url": "https://github.com/graphql-hive/console.git",
"directory": "packages/render-laboratory"
},
"author": "Michael Skorokhodov <michael@the-guild.dev>",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"exports": {
".": {
"require": {
"types": "./dist/typings/index.d.cts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./*": {
"require": {
"types": "./dist/typings/*.d.cts",
"default": "./dist/cjs/*.js"
},
"import": {
"types": "./dist/typings/*.d.ts",
"default": "./dist/esm/*.js"
},
"default": {
"types": "./dist/typings/*.d.ts",
"default": "./dist/esm/*.js"
}
},
"./package.json": "./package.json"
},
"typings": "dist/typings/index.d.ts",
"keywords": [
"graphql",
"server",
"api",
"graphql-server"
],
"scripts": {
"build": "node scripts/yoga-bundle-string.mjs && bob build",
"check": "tsc --pretty --noEmit"
},
"dependencies": {
"@graphql-hive/laboratory": "workspace:*",
"graphql-yoga": "5.13.3"
},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"buildOptions": {
"input": "./src/index.ts"
},
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}

View file

@ -0,0 +1,67 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const directoryName = path.dirname(fileURLToPath(import.meta.url));
const inputPath = path.resolve(
directoryName,
'../../../../node_modules/@graphql-hive/laboratory/dist',
);
const jsFile = path.resolve(inputPath, 'hive-laboratory.umd.js');
const cssFile = path.resolve(inputPath, 'laboratory.css');
const editorWorkerServiceFile = path.resolve(
inputPath,
'monacoeditorwork',
'editor.worker.bundle.js',
);
const graphqlWorkerFile = path.resolve(inputPath, 'monacoeditorwork', 'graphql.worker.bundle.js');
const jsonWorkerFile = path.resolve(inputPath, 'monacoeditorwork', 'json.worker.bundle.js');
const typescriptWorkerFile = path.resolve(inputPath, 'monacoeditorwork', 'ts.worker.bundle.js');
const faviconFile = path.resolve(
directoryName,
'../../../../packages/web/app/public/just-logo.svg',
);
const faviconDarkFile = path.resolve(
directoryName,
'../../../../packages/web/app/public/just-logo-black.svg',
);
const outFile = path.resolve(directoryName, '..', 'src', 'laboratory.ts');
const [
jsContents,
faviconContents,
faviconDarkContents,
cssContents,
editorWorkerServiceContents,
graphqlWorkerContents,
jsonWorkerContents,
typescriptWorkerContents,
] = await Promise.all([
fs.promises.readFile(jsFile, 'utf-8'),
fs.promises.readFile(faviconFile, 'base64'),
fs.promises.readFile(faviconDarkFile, 'base64'),
fs.promises.readFile(cssFile, 'utf-8'),
fs.promises.readFile(editorWorkerServiceFile, 'utf-8'),
fs.promises.readFile(graphqlWorkerFile, 'utf-8'),
fs.promises.readFile(jsonWorkerFile, 'utf-8'),
fs.promises.readFile(typescriptWorkerFile, 'utf-8'),
]);
await fs.promises.writeFile(
outFile,
[
`export const js: string = ${JSON.stringify(jsContents)}`,
`export const favicon: string = ${JSON.stringify(
`data:image/x-icon;base64,${faviconContents}`,
)}`,
`export const faviconDark: string = ${JSON.stringify(
`data:image/svg+xml;base64,${faviconDarkContents}`,
)}`,
`export const css: string = ${JSON.stringify(cssContents)}`,
`export const editorWorkerService: string = ${JSON.stringify(editorWorkerServiceContents)}`,
`export const graphqlWorker: string = ${JSON.stringify(graphqlWorkerContents)}`,
`export const jsonWorker: string = ${JSON.stringify(jsonWorkerContents)}`,
`export const typescriptWorker: string = ${JSON.stringify(typescriptWorkerContents)}`,
].join('\n'),
);

View file

@ -0,0 +1,70 @@
import type { GraphiQLOptions } from 'graphql-yoga';
import {
editorWorkerService,
favicon,
faviconDark,
graphqlWorker,
js,
jsonWorker,
typescriptWorker,
} from './laboratory.js';
export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>${opts?.title || 'Hive Laboratory'}</title>
<link
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: light)"
href="${favicon}"
/>
<link
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"
href="${faviconDark}"
/>
<style>
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
</style>
</head>
<body id="body" class="no-focus-outline">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
function prepareBlob(workerContent) {
const blob = new Blob([workerContent], { type: 'application/javascript' });
return URL.createObjectURL(blob);
}
const workers = {
editorWorkerService: prepareBlob(${JSON.stringify(editorWorkerService)}),
typescript: prepareBlob(${JSON.stringify(typescriptWorker)}),
json: prepareBlob(${JSON.stringify(jsonWorker)}),
graphql: prepareBlob(${JSON.stringify(graphqlWorker)}),
};
self['MonacoEnvironment'] = {
globalAPI: false,
getWorkerUrl: function (moduleId, label) {
return workers[label];
},
};
${js};
HiveLaboratory.renderLaboratory(window.document.querySelector('#root'));
</script>
</body>
</html>
`;

View file

@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.json",
"include": ["src"],
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"rootDir": "src",
"target": "es2017",
"module": "esnext",
"skipLibCheck": true,
"declaration": true,
"declarationMap": true
}
}

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../../.." "rootDir": "../../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -7,5 +7,6 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -5,5 +5,6 @@
"module": "esnext", "module": "esnext",
"rootDir": "../.." "rootDir": "../.."
}, },
"files": ["src/index.ts"] "files": ["src/index.ts"],
"include": ["src/**/*"]
} }

View file

@ -24,6 +24,7 @@
"@graphiql/react": "1.0.0-alpha.4", "@graphiql/react": "1.0.0-alpha.4",
"@graphiql/toolkit": "0.9.1", "@graphiql/toolkit": "0.9.1",
"@graphql-codegen/client-preset-swc-plugin": "0.2.0", "@graphql-codegen/client-preset-swc-plugin": "0.2.0",
"@graphql-hive/laboratory": "workspace:*",
"@graphql-inspector/core": "7.1.2", "@graphql-inspector/core": "7.1.2",
"@graphql-inspector/patch": "0.1.3", "@graphql-inspector/patch": "0.1.3",
"@graphql-tools/mock": "9.0.25", "@graphql-tools/mock": "9.0.25",

View file

@ -293,10 +293,48 @@
--chart-grid: 0 0% 45%; --chart-grid: 0 0% 45%;
} }
.hive-laboratory { :root {
--primary: 40 89% 60%; --hive-laboratory-radius: var(--radius);
--background: 223 70% 4%; --hive-laboratory-background: var(--neutral-2);
--card: 220 21.43% 5.49%; --hive-laboratory-foreground: var(--neutral-11);
--hive-laboratory-card: var(--neutral-1);
--hive-laboratory-card-foreground: var(--neutral-11);
--hive-laboratory-popover: var(--neutral-3);
--hive-laboratory-popover-foreground: var(--neutral-11);
--hive-laboratory-primary: var(--accent);
--hive-laboratory-primary-foreground: var(--neutral-1);
--hive-laboratory-secondary: var(--neutral-3);
--hive-laboratory-secondary-foreground: var(--neutral-11);
--hive-laboratory-muted: var(--neutral-3);
--hive-laboratory-muted-foreground: var(--neutral-11);
--hive-laboratory-accent: var(--neutral-4);
--hive-laboratory-accent-foreground: var(--neutral-11);
--hive-laboratory-destructive: var(--red-500);
--hive-laboratory-border: var(--neutral-5);
--hive-laboratory-input: var(--neutral-5);
--hive-laboratory-ring: var(--ring);
}
.dark {
--hive-laboratory-radius: var(--radius);
--hive-laboratory-background: var(--neutral-1);
--hive-laboratory-foreground: var(--neutral-11);
--hive-laboratory-card: var(--neutral-2);
--hive-laboratory-card-foreground: var(--neutral-11);
--hive-laboratory-popover: var(--neutral-3);
--hive-laboratory-popover-foreground: var(--neutral-11);
--hive-laboratory-primary: var(--accent);
--hive-laboratory-primary-foreground: var(--neutral-1);
--hive-laboratory-secondary: var(--neutral-3);
--hive-laboratory-secondary-foreground: var(--neutral-11);
--hive-laboratory-muted: var(--neutral-3);
--hive-laboratory-muted-foreground: var(--neutral-11);
--hive-laboratory-accent: var(--neutral-6);
--hive-laboratory-accent-foreground: var(--neutral-11);
--hive-laboratory-destructive: var(--red-500);
--hive-laboratory-border: var(--neutral-5);
--hive-laboratory-input: var(--neutral-5);
--hive-laboratory-ring: var(--ring);
} }
} }

View file

@ -1,54 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-red-200 aria-invalid:border-red-500",
{
variants: {
variant: {
default: 'bg-accent text-neutral-2 hover:bg-accent_80',
destructive: 'bg-red-500 text-neutral-12! hover:bg-red-400 focus-visible:ring-red-200',
outline: 'border bg-neutral-3 shadow-sm hover:bg-neutral-2 hover:text-neutral-12',
secondary: 'bg-neutral-2 text-neutral-11 hover:bg-neutral-2/80',
ghost: 'hover:bg-neutral-2 hover:text-neutral-12',
link: 'text-neutral-11 underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -1,19 +0,0 @@
import { cn } from '@/laboratory/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-neutral-11 placeholder:text-neutral-10 selection:bg-neutral-11 selection:text-neutral-2 border-neutral-5 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-red-200 aria-invalid:border-red-500',
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -1,24 +0,0 @@
import { cn } from '@/laboratory/lib/utils';
import * as SwitchPrimitive from '@radix-ui/react-switch';
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'data-[state=unchecked]:bg-neutral-5 focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-neutral-2 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-sm outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-neutral-3 pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View file

@ -1,16 +0,0 @@
import { cn } from '@/laboratory/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-neutral-4 placeholder:text-neutral-10 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 dark:bg-neutral-4/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -1,53 +0,0 @@
import { cn } from '@/laboratory/lib/utils';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-neutral-11 text-neutral-2 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-neutral-11 fill-neutral-11 z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

Some files were not shown because too many files have changed in this diff Show more