Added multiple components, added CodeMirror for body editor and added variable substitution for params and headers
This commit is contained in:
@@ -13,8 +13,12 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2"
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"svelte-codemirror-editor": "^2.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
|
|||||||
103
src/lib/components/code-editor.svelte
Normal file
103
src/lib/components/code-editor.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CodeMirror from "svelte-codemirror-editor";
|
||||||
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { xml } from "@codemirror/lang-xml";
|
||||||
|
import { html } from "@codemirror/lang-html";
|
||||||
|
import type { BodyType } from "$lib/types/request";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
language: BodyType;
|
||||||
|
readonly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = "",
|
||||||
|
language = "json",
|
||||||
|
readonly = false,
|
||||||
|
placeholder = "",
|
||||||
|
class: className = "",
|
||||||
|
onchange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function getLanguageSupport(lang: BodyType) {
|
||||||
|
switch (lang) {
|
||||||
|
case "json":
|
||||||
|
return json();
|
||||||
|
case "xml":
|
||||||
|
return xml();
|
||||||
|
case "html":
|
||||||
|
return html();
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(newValue: string) {
|
||||||
|
if (onchange) {
|
||||||
|
onchange(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang = $derived(getLanguageSupport(language));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="code-editor-wrapper {className}">
|
||||||
|
<CodeMirror
|
||||||
|
bind:value
|
||||||
|
{lang}
|
||||||
|
{readonly}
|
||||||
|
{placeholder}
|
||||||
|
lineNumbers={true}
|
||||||
|
tabSize={2}
|
||||||
|
lineWrapping={true}
|
||||||
|
onchange={handleChange}
|
||||||
|
styles={{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "13px",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "hsl(var(--muted))",
|
||||||
|
borderRight: "1px solid hsl(var(--border))",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: "hsl(var(--accent))",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
backgroundColor: "hsl(var(--accent) / 0.3)",
|
||||||
|
},
|
||||||
|
"&.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
caretColor: "hsl(var(--foreground))",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.code-editor-wrapper {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-wrapper :global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-wrapper :global(.cm-placeholder) {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,20 +2,26 @@
|
|||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import { Input } from "$lib/components/ui/input/index.js";
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Select from "$lib/components/ui/select/index.js";
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
import SendIcon from "@lucide/svelte/icons/send";
|
import SendIcon from "@lucide/svelte/icons/send";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
import type { Request, HttpMethod } from "$lib/types/request";
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import CodeEditor from "./code-editor.svelte";
|
||||||
|
import VariableInput from "./variable-input.svelte";
|
||||||
|
import type { Request, HttpMethod, BodyType } from "$lib/types/request";
|
||||||
|
import type { ResolvedVariable } from "$lib/types/variable";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
request: Request;
|
request: Request;
|
||||||
|
variables?: ResolvedVariable[];
|
||||||
onSend: (request: Request) => void;
|
onSend: (request: Request) => void;
|
||||||
onUpdate: (request: Request) => void;
|
onUpdate: (request: Request) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { request, onSend, onUpdate }: Props = $props();
|
let { request, variables = [], onSend, onUpdate }: Props = $props();
|
||||||
|
|
||||||
let localRequest = $state<Request>({ ...request });
|
let localRequest = $state<Request>({ ...request });
|
||||||
let activeTab = $state<"params" | "headers" | "body">("params");
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
localRequest = { ...request };
|
localRequest = { ...request };
|
||||||
@@ -30,6 +36,15 @@
|
|||||||
"HEAD",
|
"HEAD",
|
||||||
"OPTIONS",
|
"OPTIONS",
|
||||||
];
|
];
|
||||||
|
const bodyTypes: { value: BodyType; label: string }[] = [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "json", label: "JSON" },
|
||||||
|
{ value: "xml", label: "XML" },
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
{ value: "form-data", label: "Form Data" },
|
||||||
|
{ value: "x-www-form-urlencoded", label: "URL Encoded" },
|
||||||
|
];
|
||||||
|
|
||||||
function getMethodColor(method: string): string {
|
function getMethodColor(method: string): string {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
@@ -49,15 +64,39 @@
|
|||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUrlChange(e: Event) {
|
function handleUrlChange(value: string) {
|
||||||
const target = e.target as HTMLInputElement;
|
localRequest.url = value;
|
||||||
localRequest.url = target.value;
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBodyTypeChange(value: BodyType) {
|
||||||
|
localRequest.bodyType = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBodyChange(value: string) {
|
||||||
|
localRequest.body = value;
|
||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
onSend(localRequest);
|
onSend(localRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBody() {
|
||||||
|
if (localRequest.bodyType === "json") {
|
||||||
|
try {
|
||||||
|
localRequest.body = JSON.stringify(
|
||||||
|
JSON.parse(localRequest.body),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, don't format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
@@ -89,10 +128,11 @@
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
<Input
|
<VariableInput
|
||||||
class="flex-1 font-mono text-sm"
|
class="flex-1"
|
||||||
placeholder="Enter request URL"
|
placeholder={"Enter request URL (use {{VAR_NAME}} for variables)"}
|
||||||
value={localRequest.url}
|
value={localRequest.url}
|
||||||
|
{variables}
|
||||||
oninput={handleUrlChange}
|
oninput={handleUrlChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -103,70 +143,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<Tabs.Root value="params" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="border-b">
|
<Tabs.List
|
||||||
<div class="flex">
|
class="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto"
|
||||||
<button
|
>
|
||||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
<Tabs.Trigger
|
||||||
activeTab === "params"
|
value="params"
|
||||||
? "border-primary text-primary"
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onclick={() => (activeTab = "params")}
|
|
||||||
>
|
>
|
||||||
Params
|
Params
|
||||||
</button>
|
</Tabs.Trigger>
|
||||||
<button
|
<Tabs.Trigger
|
||||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
value="headers"
|
||||||
activeTab === "headers"
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onclick={() => (activeTab = "headers")}
|
|
||||||
>
|
>
|
||||||
Headers
|
Headers
|
||||||
</button>
|
</Tabs.Trigger>
|
||||||
<button
|
<Tabs.Trigger
|
||||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
value="body"
|
||||||
activeTab === "body"
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onclick={() => (activeTab = "body")}
|
|
||||||
>
|
>
|
||||||
Body
|
Body
|
||||||
</button>
|
</Tabs.Trigger>
|
||||||
</div>
|
</Tabs.List>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 p-4 overflow-auto">
|
<Tabs.Content value="params" class="flex-1 m-0 p-4 overflow-auto">
|
||||||
{#if activeTab === "params"}
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each localRequest.params as param, i (i)}
|
{#each localRequest.params as param, i (i)}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input
|
<VariableInput
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
value={param.key}
|
value={param.key}
|
||||||
oninput={(e) => {
|
{variables}
|
||||||
const target = e.target as HTMLInputElement;
|
oninput={(value) => {
|
||||||
localRequest.params[i].key = target.value;
|
localRequest.params[i].key = value;
|
||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<VariableInput
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={param.value}
|
value={param.value}
|
||||||
oninput={(e) => {
|
{variables}
|
||||||
const target = e.target as HTMLInputElement;
|
oninput={(value) => {
|
||||||
localRequest.params[i].value = target.value;
|
localRequest.params[i].value = value;
|
||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
localRequest.params = localRequest.params.filter(
|
localRequest.params = localRequest.params.filter(
|
||||||
(_, idx) => idx !== i
|
(_, idx) => idx !== i
|
||||||
@@ -174,7 +201,7 @@
|
|||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<XIcon class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -192,33 +219,35 @@
|
|||||||
Add Parameter
|
Add Parameter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === "headers"}
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="headers" class="flex-1 m-0 p-4 overflow-auto">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each localRequest.headers as header, i (i)}
|
{#each localRequest.headers as header, i (i)}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input
|
<VariableInput
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
value={header.key}
|
value={header.key}
|
||||||
oninput={(e) => {
|
{variables}
|
||||||
const target = e.target as HTMLInputElement;
|
oninput={(value) => {
|
||||||
localRequest.headers[i].key = target.value;
|
localRequest.headers[i].key = value;
|
||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<VariableInput
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={header.value}
|
value={header.value}
|
||||||
oninput={(e) => {
|
{variables}
|
||||||
const target = e.target as HTMLInputElement;
|
oninput={(value) => {
|
||||||
localRequest.headers[i].value = target.value;
|
localRequest.headers[i].value = value;
|
||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
localRequest.headers = localRequest.headers.filter(
|
localRequest.headers = localRequest.headers.filter(
|
||||||
(_, idx) => idx !== i
|
(_, idx) => idx !== i
|
||||||
@@ -226,7 +255,7 @@
|
|||||||
onUpdate(localRequest);
|
onUpdate(localRequest);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<XIcon class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -244,18 +273,99 @@
|
|||||||
Add Header
|
Add Header
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === "body"}
|
</Tabs.Content>
|
||||||
<textarea
|
|
||||||
class="w-full h-64 p-3 font-mono text-sm border rounded-md bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
<Tabs.Content value="body" class="flex-1 m-0 flex flex-col overflow-hidden">
|
||||||
placeholder="Request body (JSON, XML, etc.)"
|
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
|
||||||
value={localRequest.body}
|
<span class="text-sm text-muted-foreground">Content Type:</span>
|
||||||
oninput={(e) => {
|
<Select.Root
|
||||||
const target = e.target as HTMLTextAreaElement;
|
type="single"
|
||||||
localRequest.body = target.value;
|
value={localRequest.bodyType}
|
||||||
onUpdate(localRequest);
|
onValueChange={(value) => handleBodyTypeChange(value as BodyType)}
|
||||||
}}
|
>
|
||||||
></textarea>
|
<Select.Trigger class="w-40 h-8">
|
||||||
|
{bodyTypes.find((t) => t.value === localRequest.bodyType)?.label ||
|
||||||
|
"None"}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each bodyTypes as bodyType (bodyType.value)}
|
||||||
|
<Select.Item value={bodyType.value}>{bodyType.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if localRequest.bodyType === "json"}
|
||||||
|
<Button variant="ghost" size="sm" onclick={formatBody}>Format</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-1 p-2 overflow-hidden">
|
||||||
|
{#if localRequest.bodyType === "none"}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
This request does not have a body
|
||||||
</div>
|
</div>
|
||||||
|
{:else if localRequest.bodyType === "form-data" || localRequest.bodyType === "x-www-form-urlencoded"}
|
||||||
|
<div class="space-y-2 p-2">
|
||||||
|
{#each localRequest.formData as item, i (i)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Key"
|
||||||
|
value={item.key}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.formData[i].key = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Value"
|
||||||
|
value={item.value}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.formData[i].value = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.formData = localRequest.formData.filter(
|
||||||
|
(_, idx) => idx !== i
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.formData = [
|
||||||
|
...localRequest.formData,
|
||||||
|
{ key: "", value: "", type: "text", enabled: true },
|
||||||
|
];
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CodeEditor
|
||||||
|
value={localRequest.body}
|
||||||
|
language={localRequest.bodyType}
|
||||||
|
placeholder="Enter request body..."
|
||||||
|
class="h-full"
|
||||||
|
onchange={handleBodyChange}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
176
src/lib/components/response-panel.svelte
Normal file
176
src/lib/components/response-panel.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||||
|
import CodeEditor from "./code-editor.svelte";
|
||||||
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import type { Response } from "$lib/types/response";
|
||||||
|
import type { BodyType } from "$lib/types/request";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
response: Response | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { response, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function getStatusColor(status: number): string {
|
||||||
|
if (status >= 200 && status < 300) return "bg-green-500/10 text-green-500";
|
||||||
|
if (status >= 300 && status < 400) return "bg-blue-500/10 text-blue-500";
|
||||||
|
if (status >= 400 && status < 500)
|
||||||
|
return "bg-yellow-500/10 text-yellow-500";
|
||||||
|
if (status >= 500) return "bg-red-500/10 text-red-500";
|
||||||
|
return "bg-gray-500/10 text-gray-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms} ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBodyType(): BodyType {
|
||||||
|
if (!response) return "text";
|
||||||
|
const ct = response.contentType.toLowerCase();
|
||||||
|
if (ct.includes("json")) return "json";
|
||||||
|
if (ct.includes("xml")) return "xml";
|
||||||
|
if (ct.includes("html")) return "html";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBody(body: string, type: BodyType): string {
|
||||||
|
if (type === "json") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(body), null, 2);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
if (!response) return;
|
||||||
|
await navigator.clipboard.writeText(response.body);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyType = $derived(getBodyType());
|
||||||
|
let formattedBody = $derived(
|
||||||
|
response ? formatBody(response.body, bodyType) : ""
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full border-t">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<Spinner class="size-8" />
|
||||||
|
<span class="text-sm text-muted-foreground">Sending request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if response}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/30">
|
||||||
|
<Badge class={getStatusColor(response.status)}>
|
||||||
|
{response.status}
|
||||||
|
{response.statusText}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(response.duration)}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{formatSize(response.size)}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Button variant="ghost" size="sm" onclick={copyToClipboard}>
|
||||||
|
{#if copied}
|
||||||
|
<CheckIcon class="size-4 mr-1" />
|
||||||
|
Copied
|
||||||
|
{:else}
|
||||||
|
<CopyIcon class="size-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Root value="body" class="flex-1 flex flex-col">
|
||||||
|
<Tabs.List
|
||||||
|
class="w-full justify-start rounded-none border-b bg-transparent p-0"
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="body"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||||
|
>
|
||||||
|
Body
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="headers"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||||
|
>
|
||||||
|
Headers ({response.headers.length})
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content
|
||||||
|
value="body"
|
||||||
|
class="flex-1 m-0 p-0 data-[state=active]:flex data-[state=active]:flex-col"
|
||||||
|
>
|
||||||
|
<div class="flex-1 p-2">
|
||||||
|
<CodeEditor
|
||||||
|
value={formattedBody}
|
||||||
|
language={bodyType}
|
||||||
|
readonly={true}
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content
|
||||||
|
value="headers"
|
||||||
|
class="flex-1 m-0 data-[state=active]:flex data-[state=active]:flex-col overflow-auto"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th
|
||||||
|
class="text-left py-2 pr-4 font-medium text-muted-foreground"
|
||||||
|
>Name</th
|
||||||
|
>
|
||||||
|
<th class="text-left py-2 font-medium text-muted-foreground"
|
||||||
|
>Value</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each response.headers as header (header.key)}
|
||||||
|
<tr class="border-b last:border-0">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">{header.key}</td>
|
||||||
|
<td class="py-2 font-mono text-xs break-all"
|
||||||
|
>{header.value}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>Send a request to see the response</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
50
src/lib/components/ui/badge/badge.svelte
Normal file
50
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={href ? "a" : "span"}
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="badge"
|
||||||
|
{href}
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
2
src/lib/components/ui/badge/index.ts
Normal file
2
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Scrollbar from "./scroll-area-scrollbar.svelte";
|
||||||
|
import Root from "./scroll-area.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Scrollbar,
|
||||||
|
//,
|
||||||
|
Root as ScrollArea,
|
||||||
|
Scrollbar as ScrollAreaScrollbar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
"flex touch-none select-none p-px transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
class="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { Scrollbar } from "./index.js";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
viewportRef = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
scrollbarXClasses = "",
|
||||||
|
scrollbarYClasses = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||||
|
orientation?: "vertical" | "horizontal" | "both" | undefined;
|
||||||
|
scrollbarXClasses?: string | undefined;
|
||||||
|
scrollbarYClasses?: string | undefined;
|
||||||
|
viewportRef?: HTMLElement | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area"
|
||||||
|
class={cn("relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
bind:ref={viewportRef}
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-4"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
{#if orientation === "vertical" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||||
|
{/if}
|
||||||
|
{#if orientation === "horizontal" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||||
|
{/if}
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
37
src/lib/components/ui/select/index.ts
Normal file
37
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Group from "./select-group.svelte";
|
||||||
|
import Label from "./select-label.svelte";
|
||||||
|
import Item from "./select-item.svelte";
|
||||||
|
import Content from "./select-content.svelte";
|
||||||
|
import Trigger from "./select-trigger.svelte";
|
||||||
|
import Separator from "./select-separator.svelte";
|
||||||
|
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import GroupHeading from "./select-group-heading.svelte";
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
ScrollDownButton,
|
||||||
|
ScrollUpButton,
|
||||||
|
GroupHeading,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
|
GroupHeading as SelectGroupHeading,
|
||||||
|
};
|
||||||
42
src/lib/components/ui/select/select-content.svelte
Normal file
42
src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
preventScroll = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SelectPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Portal {...portalProps}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
{preventScroll}
|
||||||
|
data-slot="select-content"
|
||||||
|
class={cn(
|
||||||
|
"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-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md 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
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
class={cn(
|
||||||
|
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-group-heading"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.GroupHeading>
|
||||||
7
src/lib/components/ui/select/select-group.svelte
Normal file
7
src/lib/components/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||||
38
src/lib/components/ui/select/select-item.svelte
Normal file
38
src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
{value}
|
||||||
|
data-slot="select-item"
|
||||||
|
class={cn(
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected, highlighted })}
|
||||||
|
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if selected}
|
||||||
|
<CheckIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ selected, highlighted })}
|
||||||
|
{:else}
|
||||||
|
{label || value}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SelectPrimitive.Item>
|
||||||
20
src/lib/components/ui/select/select-label.svelte
Normal file
20
src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="select-label"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
18
src/lib/components/ui/select/select-separator.svelte
Normal file
18
src/lib/components/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-separator"
|
||||||
|
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
class={cn(
|
||||||
|
"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/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-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
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronDownIcon class="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
1
src/lib/components/ui/spinner/index.ts
Normal file
1
src/lib/components/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Spinner } from "./spinner.svelte";
|
||||||
14
src/lib/components/ui/spinner/spinner.svelte
Normal file
14
src/lib/components/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
class={cn("size-4 animate-spin", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
16
src/lib/components/ui/tabs/index.ts
Normal file
16
src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Root from "./tabs.svelte";
|
||||||
|
import Content from "./tabs-content.svelte";
|
||||||
|
import List from "./tabs-list.svelte";
|
||||||
|
import Trigger from "./tabs-trigger.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Tabs,
|
||||||
|
Content as TabsContent,
|
||||||
|
List as TabsList,
|
||||||
|
Trigger as TabsTrigger,
|
||||||
|
};
|
||||||
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-content"
|
||||||
|
class={cn("flex-1 outline-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.List
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-list"
|
||||||
|
class={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark: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
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(""),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="tabs"
|
||||||
|
class={cn("flex flex-col gap-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/lib/components/ui/textarea/index.ts
Normal file
7
src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./textarea.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Textarea,
|
||||||
|
};
|
||||||
23
src/lib/components/ui/textarea/textarea.svelte
Normal file
23
src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "textarea",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={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 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
></textarea>
|
||||||
167
src/lib/components/variable-input.svelte
Normal file
167
src/lib/components/variable-input.svelte
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import type { ResolvedVariable } from "$lib/types/variable";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
variables: ResolvedVariable[];
|
||||||
|
oninput?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = "",
|
||||||
|
placeholder = "",
|
||||||
|
class: className = "",
|
||||||
|
variables = [],
|
||||||
|
oninput,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
let filteredVariables = $state<ResolvedVariable[]>([]);
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let inputElement: HTMLInputElement | null = $state(null);
|
||||||
|
let cursorPosition = $state(0);
|
||||||
|
|
||||||
|
function findVariableContext(
|
||||||
|
text: string,
|
||||||
|
position: number
|
||||||
|
): { start: number; query: string } | null {
|
||||||
|
const beforeCursor = text.substring(0, position);
|
||||||
|
const match = beforeCursor.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)?$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
start: match.index! + 2,
|
||||||
|
query: (match[1] || "").toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
cursorPosition = target.selectionStart || 0;
|
||||||
|
|
||||||
|
const context = findVariableContext(target.value, cursorPosition);
|
||||||
|
if (context) {
|
||||||
|
filteredVariables = variables.filter((v) =>
|
||||||
|
v.name.toLowerCase().includes(context.query)
|
||||||
|
);
|
||||||
|
showSuggestions = filteredVariables.length > 0;
|
||||||
|
selectedIndex = 0;
|
||||||
|
} else {
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oninput) {
|
||||||
|
oninput(target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(
|
||||||
|
selectedIndex + 1,
|
||||||
|
filteredVariables.length - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case "Tab":
|
||||||
|
if (filteredVariables.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertVariable(filteredVariables[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
showSuggestions = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertVariable(variable: ResolvedVariable) {
|
||||||
|
const context = findVariableContext(value, cursorPosition);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const before = value.substring(0, context.start);
|
||||||
|
const after = value.substring(cursorPosition);
|
||||||
|
const newValue = `${before}${variable.name}}}${after}`;
|
||||||
|
|
||||||
|
if (oninput) {
|
||||||
|
oninput(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuggestions = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputElement) {
|
||||||
|
const newPosition = context.start + variable.name.length + 2;
|
||||||
|
inputElement.setSelectionRange(newPosition, newPosition);
|
||||||
|
inputElement.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuggestions = false;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopeColor(scope: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
global: "text-purple-500",
|
||||||
|
workspace: "text-blue-500",
|
||||||
|
collection: "text-green-500",
|
||||||
|
request: "text-orange-500",
|
||||||
|
};
|
||||||
|
return colors[scope] || "text-gray-500";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {className}">
|
||||||
|
<Input
|
||||||
|
bind:ref={inputElement}
|
||||||
|
{value}
|
||||||
|
{placeholder}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
onblur={handleBlur}
|
||||||
|
class="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showSuggestions}
|
||||||
|
<div
|
||||||
|
class="absolute z-50 top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg max-h-48 overflow-auto"
|
||||||
|
>
|
||||||
|
{#each filteredVariables as variable, i (variable.name)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-accent flex items-center justify-between gap-2 {i ===
|
||||||
|
selectedIndex
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''}"
|
||||||
|
onmousedown={() => insertVariable(variable)}
|
||||||
|
>
|
||||||
|
<span class="font-mono">
|
||||||
|
{#if variable.isSecret}
|
||||||
|
🔒
|
||||||
|
{/if}
|
||||||
|
{variable.name}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs {getScopeColor(variable.scope)}"
|
||||||
|
>{variable.scope}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
|||||||
url: "https://api.example.com/users",
|
url: "https://api.example.com/users",
|
||||||
headers: [],
|
headers: [],
|
||||||
params: [],
|
params: [],
|
||||||
|
bodyType: "none",
|
||||||
body: "",
|
body: "",
|
||||||
|
formData: [],
|
||||||
collectionId: "col-1",
|
collectionId: "col-1",
|
||||||
workspaceId: "1",
|
workspaceId: "1",
|
||||||
},
|
},
|
||||||
@@ -30,7 +32,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
|||||||
{ key: "Content-Type", value: "application/json", enabled: true },
|
{ key: "Content-Type", value: "application/json", enabled: true },
|
||||||
],
|
],
|
||||||
params: [],
|
params: [],
|
||||||
|
bodyType: "json",
|
||||||
body: '{"name": "John", "email": "john@example.com"}',
|
body: '{"name": "John", "email": "john@example.com"}',
|
||||||
|
formData: [],
|
||||||
collectionId: "col-1",
|
collectionId: "col-1",
|
||||||
workspaceId: "1",
|
workspaceId: "1",
|
||||||
},
|
},
|
||||||
@@ -52,7 +56,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
|||||||
url: "https://api.example.com/products",
|
url: "https://api.example.com/products",
|
||||||
headers: [],
|
headers: [],
|
||||||
params: [{ key: "limit", value: "10", enabled: true }],
|
params: [{ key: "limit", value: "10", enabled: true }],
|
||||||
|
bodyType: "none",
|
||||||
body: "",
|
body: "",
|
||||||
|
formData: [],
|
||||||
collectionId: "col-2",
|
collectionId: "col-2",
|
||||||
workspaceId: "1",
|
workspaceId: "1",
|
||||||
},
|
},
|
||||||
@@ -71,7 +77,9 @@ const standaloneRequests: Map<string, Request> = new Map<string, Request>([
|
|||||||
url: "https://api.example.com/health",
|
url: "https://api.example.com/health",
|
||||||
headers: [],
|
headers: [],
|
||||||
params: [],
|
params: [],
|
||||||
|
bodyType: "none",
|
||||||
body: "",
|
body: "",
|
||||||
|
formData: [],
|
||||||
collectionId: null,
|
collectionId: null,
|
||||||
workspaceId: "1",
|
workspaceId: "1",
|
||||||
},
|
},
|
||||||
|
|||||||
199
src/lib/services/variables.ts
Normal file
199
src/lib/services/variables.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
Variable,
|
||||||
|
VariableScope,
|
||||||
|
ResolvedVariable,
|
||||||
|
} from "$lib/types/variable";
|
||||||
|
|
||||||
|
const variables: Map<string, Variable> = new Map<string, Variable>([
|
||||||
|
[
|
||||||
|
"var-1",
|
||||||
|
{
|
||||||
|
id: "var-1",
|
||||||
|
name: "BASE_URL",
|
||||||
|
value: "https://api.example.com",
|
||||||
|
scope: "global",
|
||||||
|
scopeId: null,
|
||||||
|
isSecret: false,
|
||||||
|
description: "Base URL for all API requests",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var-2",
|
||||||
|
{
|
||||||
|
id: "var-2",
|
||||||
|
name: "API_KEY",
|
||||||
|
value: "sk-1234567890",
|
||||||
|
scope: "global",
|
||||||
|
scopeId: null,
|
||||||
|
isSecret: true,
|
||||||
|
description: "API authentication key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var-3",
|
||||||
|
{
|
||||||
|
id: "var-3",
|
||||||
|
name: "USER_ID",
|
||||||
|
value: "user-123",
|
||||||
|
scope: "workspace",
|
||||||
|
scopeId: "1",
|
||||||
|
isSecret: false,
|
||||||
|
description: "Default user ID for testing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var-4",
|
||||||
|
{
|
||||||
|
id: "var-4",
|
||||||
|
name: "AUTH_TOKEN",
|
||||||
|
value: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||||
|
scope: "workspace",
|
||||||
|
scopeId: "1",
|
||||||
|
isSecret: true,
|
||||||
|
description: "Authentication token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function get_all_variables(): Promise<Variable[]> {
|
||||||
|
return [...variables.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_variables_by_scope(
|
||||||
|
scope: VariableScope,
|
||||||
|
scopeId: string | null
|
||||||
|
): Promise<Variable[]> {
|
||||||
|
return [...variables.values()].filter(
|
||||||
|
(v) => v.scope === scope && v.scopeId === scopeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_global_variables(): Promise<Variable[]> {
|
||||||
|
return get_variables_by_scope("global", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_workspace_variables(
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<Variable[]> {
|
||||||
|
return get_variables_by_scope("workspace", workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_collection_variables(
|
||||||
|
collectionId: string
|
||||||
|
): Promise<Variable[]> {
|
||||||
|
return get_variables_by_scope("collection", collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_request_variables(
|
||||||
|
requestId: string
|
||||||
|
): Promise<Variable[]> {
|
||||||
|
return get_variables_by_scope("request", requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_resolved_variables(
|
||||||
|
workspaceId: string,
|
||||||
|
collectionId: string | null,
|
||||||
|
requestId: string | null
|
||||||
|
): Promise<ResolvedVariable[]> {
|
||||||
|
const resolved: Map<string, ResolvedVariable> = new Map();
|
||||||
|
|
||||||
|
const globalVars = await get_global_variables();
|
||||||
|
for (const v of globalVars) {
|
||||||
|
resolved.set(v.name, {
|
||||||
|
name: v.name,
|
||||||
|
value: v.value,
|
||||||
|
scope: v.scope,
|
||||||
|
isSecret: v.isSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceVars = await get_workspace_variables(workspaceId);
|
||||||
|
for (const v of workspaceVars) {
|
||||||
|
resolved.set(v.name, {
|
||||||
|
name: v.name,
|
||||||
|
value: v.value,
|
||||||
|
scope: v.scope,
|
||||||
|
isSecret: v.isSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
const collectionVars = await get_collection_variables(collectionId);
|
||||||
|
for (const v of collectionVars) {
|
||||||
|
resolved.set(v.name, {
|
||||||
|
name: v.name,
|
||||||
|
value: v.value,
|
||||||
|
scope: v.scope,
|
||||||
|
isSecret: v.isSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId) {
|
||||||
|
const requestVars = await get_request_variables(requestId);
|
||||||
|
for (const v of requestVars) {
|
||||||
|
resolved.set(v.name, {
|
||||||
|
name: v.name,
|
||||||
|
value: v.value,
|
||||||
|
scope: v.scope,
|
||||||
|
isSecret: v.isSecret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...resolved.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_variable(id: string): Promise<Variable | undefined> {
|
||||||
|
return variables.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create_variable(
|
||||||
|
variable: Omit<Variable, "id">
|
||||||
|
): Promise<Variable> {
|
||||||
|
const newVariable: Variable = {
|
||||||
|
...variable,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
variables.set(newVariable.id, newVariable);
|
||||||
|
return newVariable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_variable(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<Variable, "id">>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const variable = variables.get(id);
|
||||||
|
if (!variable) return false;
|
||||||
|
|
||||||
|
Object.assign(variable, updates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_variable(id: string): Promise<boolean> {
|
||||||
|
return variables.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolate_variables(
|
||||||
|
text: string,
|
||||||
|
resolvedVariables: ResolvedVariable[]
|
||||||
|
): string {
|
||||||
|
let result = text;
|
||||||
|
for (const variable of resolvedVariables) {
|
||||||
|
const pattern = new RegExp(`\\{\\{\\s*${variable.name}\\s*\\}\\}`, "g");
|
||||||
|
result = result.replace(pattern, variable.value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extract_variable_names(text: string): string[] {
|
||||||
|
const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
if (!matches.includes(match[1])) {
|
||||||
|
matches.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ export type HttpMethod =
|
|||||||
| "HEAD"
|
| "HEAD"
|
||||||
| "OPTIONS";
|
| "OPTIONS";
|
||||||
|
|
||||||
|
export type BodyType =
|
||||||
|
| "none"
|
||||||
|
| "json"
|
||||||
|
| "xml"
|
||||||
|
| "text"
|
||||||
|
| "html"
|
||||||
|
| "form-data"
|
||||||
|
| "x-www-form-urlencoded";
|
||||||
|
|
||||||
export type RequestHeader = {
|
export type RequestHeader = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -19,6 +28,13 @@ export type RequestParam = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FormDataItem = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
type: "text" | "file";
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Request = {
|
export type Request = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,7 +42,9 @@ export type Request = {
|
|||||||
url: string;
|
url: string;
|
||||||
headers: RequestHeader[];
|
headers: RequestHeader[];
|
||||||
params: RequestParam[];
|
params: RequestParam[];
|
||||||
|
bodyType: BodyType;
|
||||||
body: string;
|
body: string;
|
||||||
|
formData: FormDataItem[];
|
||||||
collectionId: string | null;
|
collectionId: string | null;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
14
src/lib/types/response.ts
Normal file
14
src/lib/types/response.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type ResponseHeader = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Response = {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: ResponseHeader[];
|
||||||
|
body: string;
|
||||||
|
contentType: string;
|
||||||
|
duration: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
18
src/lib/types/variable.ts
Normal file
18
src/lib/types/variable.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type VariableScope = "global" | "workspace" | "collection" | "request";
|
||||||
|
|
||||||
|
export type Variable = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
scope: VariableScope;
|
||||||
|
scopeId: string | null;
|
||||||
|
isSecret: boolean;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedVariable = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
scope: VariableScope;
|
||||||
|
isSecret: boolean;
|
||||||
|
};
|
||||||
@@ -21,9 +21,13 @@
|
|||||||
update_request,
|
update_request,
|
||||||
delete_request,
|
delete_request,
|
||||||
} from "$lib/services/collections";
|
} from "$lib/services/collections";
|
||||||
|
import { get_resolved_variables } from "$lib/services/variables";
|
||||||
import type { Workspace } from "$lib/types/workspace";
|
import type { Workspace } from "$lib/types/workspace";
|
||||||
import type { Collection } from "$lib/types/collection";
|
import type { Collection } from "$lib/types/collection";
|
||||||
import type { Request, HttpMethod } from "$lib/types/request";
|
import type { Request, HttpMethod } from "$lib/types/request";
|
||||||
|
import type { Response } from "$lib/types/response";
|
||||||
|
import type { ResolvedVariable } from "$lib/types/variable";
|
||||||
|
import ResponsePanel from "$lib/components/response-panel.svelte";
|
||||||
|
|
||||||
const { params } = $props<{ params: { id: string } }>();
|
const { params } = $props<{ params: { id: string } }>();
|
||||||
|
|
||||||
@@ -31,6 +35,9 @@
|
|||||||
let collections = $state<Collection[]>([]);
|
let collections = $state<Collection[]>([]);
|
||||||
let standaloneRequests = $state<Request[]>([]);
|
let standaloneRequests = $state<Request[]>([]);
|
||||||
let selectedRequest = $state<Request | null>(null);
|
let selectedRequest = $state<Request | null>(null);
|
||||||
|
let response = $state<Response | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let resolvedVariables = $state<ResolvedVariable[]>([]);
|
||||||
|
|
||||||
let collectionDialogOpen = $state(false);
|
let collectionDialogOpen = $state(false);
|
||||||
let collectionDialogMode = $state<"create" | "edit">("create");
|
let collectionDialogMode = $state<"create" | "edit">("create");
|
||||||
@@ -59,6 +66,7 @@
|
|||||||
workspace = await get_workspace(params.id);
|
workspace = await get_workspace(params.id);
|
||||||
collections = await get_collections_by_workspace(params.id);
|
collections = await get_collections_by_workspace(params.id);
|
||||||
standaloneRequests = await get_standalone_requests_by_workspace(params.id);
|
standaloneRequests = await get_standalone_requests_by_workspace(params.id);
|
||||||
|
resolvedVariables = await get_resolved_variables(params.id, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@@ -67,6 +75,7 @@
|
|||||||
|
|
||||||
function handleRequestSelect(request: Request) {
|
function handleRequestSelect(request: Request) {
|
||||||
selectedRequest = request;
|
selectedRequest = request;
|
||||||
|
response = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateCollection() {
|
function handleCreateCollection() {
|
||||||
@@ -138,7 +147,9 @@
|
|||||||
url: "",
|
url: "",
|
||||||
headers: [],
|
headers: [],
|
||||||
params: [],
|
params: [],
|
||||||
|
bodyType: "none",
|
||||||
body: "",
|
body: "",
|
||||||
|
formData: [],
|
||||||
collectionId: requestCollectionId,
|
collectionId: requestCollectionId,
|
||||||
workspaceId: params.id,
|
workspaceId: params.id,
|
||||||
});
|
});
|
||||||
@@ -169,8 +180,59 @@
|
|||||||
deleteTarget = null;
|
deleteTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendRequest(request: Request) {
|
async function handleSendRequest(request: Request) {
|
||||||
console.log("Sending request:", request);
|
loading = true;
|
||||||
|
response = null;
|
||||||
|
|
||||||
|
// Simulate API request (will be replaced with actual Tauri call later)
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock response for now
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 500 + Math.random() * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockBody = JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "This is a mock response",
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
name: "Example",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
response = {
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: [
|
||||||
|
{ key: "Content-Type", value: "application/json" },
|
||||||
|
{ key: "X-Request-Id", value: crypto.randomUUID() },
|
||||||
|
{ key: "Cache-Control", value: "no-cache" },
|
||||||
|
],
|
||||||
|
body: mockBody,
|
||||||
|
contentType: "application/json",
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
size: new Blob([mockBody]).size,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
response = {
|
||||||
|
status: 500,
|
||||||
|
statusText: "Error",
|
||||||
|
headers: [],
|
||||||
|
body: JSON.stringify({ error: "Request failed" }),
|
||||||
|
contentType: "application/json",
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateRequest(request: Request) {
|
async function handleUpdateRequest(request: Request) {
|
||||||
@@ -220,13 +282,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
{#if selectedRequest}
|
{#if selectedRequest}
|
||||||
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<RequestPanel
|
<RequestPanel
|
||||||
request={selectedRequest}
|
request={selectedRequest}
|
||||||
|
variables={resolvedVariables}
|
||||||
onSend={handleSendRequest}
|
onSend={handleSendRequest}
|
||||||
onUpdate={handleUpdateRequest}
|
onUpdate={handleUpdateRequest}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="h-[300px] min-h-[200px] border-t">
|
||||||
|
<ResponsePanel {response} {loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Empty.Root class="flex h-full items-center justify-center">
|
<Empty.Root class="flex h-full items-center justify-center">
|
||||||
<Empty.Header>
|
<Empty.Header>
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: [
|
||||||
|
"svelte-codemirror-editor",
|
||||||
|
"codemirror",
|
||||||
|
"@codemirror/lang-json",
|
||||||
|
"@codemirror/lang-xml",
|
||||||
|
"@codemirror/lang-html",
|
||||||
|
],
|
||||||
|
},
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent vite from obscuring rust errors
|
// 1. prevent vite from obscuring rust errors
|
||||||
@@ -18,7 +27,9 @@ export default defineConfig(async () => ({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||||
watch: { // 3. tell vite to ignore watching `src-tauri`
|
watch: {
|
||||||
ignored: ["**/src-tauri/**"] }
|
// 3. tell vite to ignore watching `src-tauri`
|
||||||
}
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user