adding duplication and syncing of workspaces, a settings dialog and variable listing and editing

This commit is contained in:
xyroscar
2025-11-26 11:25:57 -08:00
parent e2a7761388
commit ce75694ffb
9 changed files with 1587 additions and 35 deletions

View File

@@ -0,0 +1,338 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import CopyIcon from "@lucide/svelte/icons/copy";
import LinkIcon from "@lucide/svelte/icons/link";
import type { Workspace } from "$lib/types/workspace";
import type { Variable } from "$lib/types/variable";
import { get_workspace_variables } from "$lib/services/variables";
import {
duplicate_workspace,
type DuplicateWorkspaceOptions,
} from "$lib/services/sync";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceWorkspace: Workspace | null;
onDuplicate: (options: DuplicateWorkspaceOptions) => Promise<void>;
};
let {
open = $bindable(),
onOpenChange,
sourceWorkspace,
onDuplicate,
}: Props = $props();
let newName = $state("");
let newDescription = $state("");
let environment = $state("");
let copyVariables = $state(true);
let copySecrets = $state(false);
let createSyncGroup = $state(false);
let syncGroupName = $state("");
let variablesToSync = $state<string[]>([]);
let workspaceVariables = $state<Variable[]>([]);
let loading = $state(false);
$effect(() => {
if (open && sourceWorkspace) {
newName = `${sourceWorkspace.Name} (Copy)`;
newDescription = sourceWorkspace.Description;
environment = "";
syncGroupName = `${sourceWorkspace.Name} Environments`;
loadVariables();
}
});
async function loadVariables() {
if (sourceWorkspace) {
workspaceVariables = await get_workspace_variables(sourceWorkspace.Id);
}
}
function toggleVariableSync(varName: string) {
if (variablesToSync.includes(varName)) {
variablesToSync = variablesToSync.filter((v) => v !== varName);
} else {
variablesToSync = [...variablesToSync, varName];
}
}
function selectAllVariables() {
variablesToSync = workspaceVariables
.filter((v) => !v.isSecret || copySecrets)
.map((v) => v.name);
}
function deselectAllVariables() {
variablesToSync = [];
}
async function handleSubmit() {
if (!sourceWorkspace) return;
loading = true;
try {
await onDuplicate({
sourceWorkspaceId: sourceWorkspace.Id,
newName,
newDescription,
environment: environment || undefined,
copyVariables,
copySecrets,
createSyncGroup,
syncGroupName: createSyncGroup ? syncGroupName : undefined,
variablesToSync: createSyncGroup ? variablesToSync : undefined,
});
onOpenChange(false);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[550px] max-h-[85vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<CopyIcon class="size-5" />
Duplicate Workspace
</Dialog.Title>
<Dialog.Description>
Create a copy of "{sourceWorkspace?.Name}" with all its collections and
requests.
</Dialog.Description>
</Dialog.Header>
<form
class="flex-1 overflow-auto space-y-4 py-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-name" class="text-end">Name</Label>
<Input
id="new-name"
class="col-span-3"
placeholder="New workspace name"
bind:value={newName}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-desc" class="text-end">Description</Label>
<Textarea
id="new-desc"
class="col-span-3"
placeholder="Workspace description"
bind:value={newDescription}
rows={2}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="environment" class="text-end">Environment</Label>
<Input
id="environment"
class="col-span-3"
placeholder="e.g., Development, Staging, Production"
bind:value={environment}
/>
</div>
</div>
<Separator />
<div class="space-y-4">
<h4 class="font-medium">Variables</h4>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Variables</Label>
<p class="text-xs text-muted-foreground">
Copy workspace variables to the new workspace.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copyVariables}
aria-label="Toggle copy variables"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copyVariables
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copyVariables = !copyVariables)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copyVariables
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if copyVariables}
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Secret Values</Label>
<p class="text-xs text-muted-foreground">
Copy secret values (otherwise they'll be empty).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copySecrets}
aria-label="Toggle copy secrets"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copySecrets
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copySecrets = !copySecrets)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copySecrets
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{/if}
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<LinkIcon class="size-4" />
<Label>Create Sync Group</Label>
</div>
<p class="text-xs text-muted-foreground">
Link workspaces to sync selected variables between environments.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={createSyncGroup}
aria-label="Toggle create sync group"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {createSyncGroup
? 'bg-primary'
: 'bg-input'}"
onclick={() => (createSyncGroup = !createSyncGroup)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {createSyncGroup
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if createSyncGroup}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sync-name" class="text-end">Group Name</Label>
<Input
id="sync-name"
class="col-span-3"
placeholder="Sync group name"
bind:value={syncGroupName}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Variables to Sync</Label>
<div class="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onclick={selectAllVariables}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={deselectAllVariables}
>
Deselect All
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-2">
Selected variables will be kept in sync across linked workspaces.
</p>
{#if workspaceVariables.length === 0}
<div class="text-center py-4 text-muted-foreground text-sm">
No workspace variables to sync.
</div>
{:else}
<div
class="grid grid-cols-2 gap-2 max-h-[150px] overflow-auto p-2 border rounded-md"
>
{#each workspaceVariables as variable (variable.id)}
{#if !variable.isSecret || copySecrets}
<button
type="button"
class="flex items-center gap-2 p-2 rounded text-left text-sm hover:bg-accent transition-colors {variablesToSync.includes(
variable.name
)
? 'bg-accent'
: ''}"
onclick={() => toggleVariableSync(variable.name)}
>
<input
type="checkbox"
checked={variablesToSync.includes(variable.name)}
class="rounded"
onchange={() => {}}
/>
<span class="font-mono text-xs truncate"
>{variable.name}</span
>
{#if variable.isSecret}
<Badge variant="secondary" class="text-xs">secret</Badge
>
{/if}
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="pt-4">
<Button
type="button"
variant="outline"
onclick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !newName.trim()}>
{loading ? "Creating..." : "Duplicate Workspace"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import SettingsIcon from "@lucide/svelte/icons/settings";
import type { AppSettings } from "$lib/types/workspace";
import {
get_settings,
update_settings,
reset_settings,
} from "$lib/services/settings";
import { onMount } from "svelte";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
let { open = $bindable(), onOpenChange }: Props = $props();
let settings = $state<AppSettings>({
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
});
let loading = $state(false);
onMount(async () => {
settings = await get_settings();
});
$effect(() => {
if (open) {
loadSettings();
}
});
async function loadSettings() {
settings = await get_settings();
}
async function handleSave() {
loading = true;
await update_settings(settings);
loading = false;
onOpenChange(false);
}
async function handleReset() {
settings = await reset_settings();
}
function handleThemeChange(value: string) {
settings.theme = value as "light" | "dark" | "system";
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<SettingsIcon class="size-5" />
Settings
</Dialog.Title>
<Dialog.Description>
Configure application settings and preferences.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="general" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="requests">Requests</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="general" class="space-y-4">
<div class="space-y-2">
<Label for="theme">Theme</Label>
<Select.Root
type="single"
value={settings.theme}
onValueChange={handleThemeChange}
>
<Select.Trigger id="theme" class="w-full">
{settings.theme === "light"
? "Light"
: settings.theme === "dark"
? "Dark"
: "System"}
</Select.Trigger>
<Select.Content>
<Select.Item value="system">System</Select.Item>
<Select.Item value="light">Light</Select.Item>
<Select.Item value="dark">Dark</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Choose the application color theme.
</p>
</div>
<Separator />
<div class="space-y-2">
<Label for="maxHistory">History Items</Label>
<Input
id="maxHistory"
type="number"
min="10"
max="1000"
bind:value={settings.maxHistoryItems}
/>
<p class="text-xs text-muted-foreground">
Maximum number of request history items to keep.
</p>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Auto-save Requests</Label>
<p class="text-xs text-muted-foreground">
Automatically save request changes.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.autoSaveRequests}
aria-label="Toggle auto-save requests"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.autoSaveRequests
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.autoSaveRequests = !settings.autoSaveRequests)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.autoSaveRequests
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="requests" class="space-y-4">
<div class="space-y-2">
<Label for="timeout">Default Timeout (ms)</Label>
<Input
id="timeout"
type="number"
min="1000"
max="300000"
step="1000"
bind:value={settings.defaultTimeout}
/>
<p class="text-xs text-muted-foreground">
Default timeout for HTTP requests in milliseconds.
</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Follow Redirects</Label>
<p class="text-xs text-muted-foreground">
Automatically follow HTTP redirects.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.followRedirects}
aria-label="Toggle follow redirects"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.followRedirects
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.followRedirects = !settings.followRedirects)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.followRedirects
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Validate SSL Certificates</Label>
<p class="text-xs text-muted-foreground">
Verify SSL/TLS certificates for HTTPS requests.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.validateSSL}
aria-label="Toggle SSL validation"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSSL
? 'bg-primary'
: 'bg-input'}"
onclick={() => (settings.validateSSL = !settings.validateSSL)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSSL
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="advanced" class="space-y-4">
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Reset Settings</h4>
<p class="text-sm text-muted-foreground mb-4">
Reset all settings to their default values. This action cannot be
undone.
</p>
<Button variant="destructive" size="sm" onclick={handleReset}>
Reset to Defaults
</Button>
</div>
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Data Management</h4>
<p class="text-sm text-muted-foreground mb-4">
Export or import your workspaces, collections, and settings.
</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" disabled>Export Data</Button>
<Button variant="outline" size="sm" disabled>Import Data</Button>
</div>
</div>
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onclick={handleSave} disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,418 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import PencilIcon from "@lucide/svelte/icons/pencil";
import LockIcon from "@lucide/svelte/icons/lock";
import EyeIcon from "@lucide/svelte/icons/eye";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import VariableIcon from "@lucide/svelte/icons/variable";
import type { Variable, VariableScope } from "$lib/types/variable";
import {
get_global_variables,
get_workspace_variables,
get_collection_variables,
create_variable,
update_variable,
delete_variable,
} from "$lib/services/variables";
import { onMount } from "svelte";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId?: string;
collectionId?: string;
};
let {
open = $bindable(),
onOpenChange,
workspaceId,
collectionId,
}: Props = $props();
let globalVariables = $state<Variable[]>([]);
let workspaceVariables = $state<Variable[]>([]);
let collectionVariables = $state<Variable[]>([]);
let editDialogOpen = $state(false);
let editingVariable = $state<Variable | null>(null);
let isCreating = $state(false);
let formName = $state("");
let formValue = $state("");
let formDescription = $state("");
let formIsSecret = $state(false);
let formScope = $state<VariableScope>("global");
let showSecretValues = $state<Set<string>>(new Set());
$effect(() => {
if (open) {
loadVariables();
}
});
async function loadVariables() {
globalVariables = await get_global_variables();
if (workspaceId) {
workspaceVariables = await get_workspace_variables(workspaceId);
}
if (collectionId) {
collectionVariables = await get_collection_variables(collectionId);
}
}
function openCreateDialog(scope: VariableScope) {
isCreating = true;
editingVariable = null;
formName = "";
formValue = "";
formDescription = "";
formIsSecret = false;
formScope = scope;
editDialogOpen = true;
}
function openEditDialog(variable: Variable) {
isCreating = false;
editingVariable = variable;
formName = variable.name;
formValue = variable.value;
formDescription = variable.description || "";
formIsSecret = variable.isSecret;
formScope = variable.scope;
editDialogOpen = true;
}
async function handleSave() {
const scopeId =
formScope === "global"
? null
: formScope === "workspace"
? (workspaceId ?? null)
: formScope === "collection"
? (collectionId ?? null)
: null;
if (isCreating) {
await create_variable({
name: formName,
value: formValue,
scope: formScope,
scopeId,
isSecret: formIsSecret,
description: formDescription || undefined,
});
} else if (editingVariable) {
await update_variable(editingVariable.id, {
name: formName,
value: formValue,
isSecret: formIsSecret,
description: formDescription || undefined,
});
}
await loadVariables();
editDialogOpen = false;
}
async function handleDelete(variable: Variable) {
await delete_variable(variable.id);
await loadVariables();
}
function toggleSecretVisibility(id: string) {
if (showSecretValues.has(id)) {
showSecretValues.delete(id);
} else {
showSecretValues.add(id);
}
showSecretValues = new Set(showSecretValues);
}
function getScopeColor(scope: VariableScope): string {
const colors: Record<VariableScope, string> = {
global: "bg-purple-500/10 text-purple-500",
workspace: "bg-blue-500/10 text-blue-500",
collection: "bg-green-500/10 text-green-500",
request: "bg-orange-500/10 text-orange-500",
};
return colors[scope];
}
function getMaskedValue(value: string): string {
return "•".repeat(Math.min(value.length, 20));
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[700px] max-h-[80vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<VariableIcon class="size-5" />
Variables & Secrets
</Dialog.Title>
<Dialog.Description>
Manage variables and secrets at different scopes. Variables can be used
in requests with {"{{VAR_NAME}}"} syntax.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="global" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="global">Global</Tabs.Trigger>
<Tabs.Trigger value="workspace" disabled={!workspaceId}
>Workspace</Tabs.Trigger
>
<Tabs.Trigger value="collection" disabled={!collectionId}
>Collection</Tabs.Trigger
>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="global" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Global variables are available in all workspaces.
</p>
<Button size="sm" onclick={() => openCreateDialog("global")}>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if globalVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No global variables defined.
</div>
{:else}
{#each globalVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
<Tabs.Content value="workspace" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Workspace variables are available in this workspace only.
</p>
<Button
size="sm"
onclick={() => openCreateDialog("workspace")}
disabled={!workspaceId}
>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if workspaceVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No workspace variables defined.
</div>
{:else}
{#each workspaceVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
<Tabs.Content value="collection" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Collection variables are available in this collection only.
</p>
<Button
size="sm"
onclick={() => openCreateDialog("collection")}
disabled={!collectionId}
>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if collectionVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No collection variables defined.
</div>
{:else}
{#each collectionVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => onOpenChange(false)}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#snippet variableRow(variable: Variable)}
<div
class="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono font-medium text-sm">{variable.name}</span>
{#if variable.isSecret}
<LockIcon class="size-3 text-muted-foreground" />
{/if}
<Badge class={getScopeColor(variable.scope)} variant="secondary">
{variable.scope}
</Badge>
</div>
<div class="flex items-center gap-2 mt-1">
<code
class="text-xs text-muted-foreground font-mono truncate max-w-[300px]"
>
{#if variable.isSecret && !showSecretValues.has(variable.id)}
{getMaskedValue(variable.value)}
{:else}
{variable.value}
{/if}
</code>
{#if variable.isSecret}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() => toggleSecretVisibility(variable.id)}
aria-label={showSecretValues.has(variable.id)
? "Hide value"
: "Show value"}
>
{#if showSecretValues.has(variable.id)}
<EyeOffIcon class="size-3" />
{:else}
<EyeIcon class="size-3" />
{/if}
</button>
{/if}
</div>
{#if variable.description}
<p class="text-xs text-muted-foreground mt-1">{variable.description}</p>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onclick={() => openEditDialog(variable)}
aria-label="Edit variable"
>
<PencilIcon class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onclick={() => handleDelete(variable)}
aria-label="Delete variable"
>
<TrashIcon class="size-4 text-destructive" />
</Button>
</div>
</div>
{/snippet}
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>
{isCreating ? "Create Variable" : "Edit Variable"}
</Dialog.Title>
<Dialog.Description>
{isCreating
? "Add a new variable or secret."
: "Update variable details."}
</Dialog.Description>
</Dialog.Header>
<form
class="grid gap-4 py-4"
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-name" class="text-end">Name</Label>
<Input
id="var-name"
class="col-span-3 font-mono"
placeholder="VARIABLE_NAME"
bind:value={formName}
pattern="[A-Za-z_][A-Za-z0-9_]*"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-value" class="text-end">Value</Label>
<Input
id="var-value"
class="col-span-3 font-mono"
type={formIsSecret ? "password" : "text"}
placeholder="Variable value"
bind:value={formValue}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-desc" class="text-end">Description</Label>
<Textarea
id="var-desc"
class="col-span-3"
placeholder="Optional description"
bind:value={formDescription}
rows={2}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-end">Secret</Label>
<div class="col-span-3 flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={formIsSecret}
aria-label="Mark as secret"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {formIsSecret
? 'bg-primary'
: 'bg-input'}"
onclick={() => (formIsSecret = !formIsSecret)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {formIsSecret
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
<span class="text-sm text-muted-foreground">
{formIsSecret ? "Value will be hidden" : "Value will be visible"}
</span>
</div>
</div>
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (editDialogOpen = false)}
>
Cancel
</Button>
<Button type="submit" disabled={!formName.trim()}>
{isCreating ? "Create" : "Save"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,33 @@
import type { AppSettings } from "$lib/types/workspace";
let settings: AppSettings = {
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
};
export async function get_settings(): Promise<AppSettings> {
return { ...settings };
}
export async function update_settings(
updates: Partial<AppSettings>
): Promise<AppSettings> {
settings = { ...settings, ...updates };
return { ...settings };
}
export async function reset_settings(): Promise<AppSettings> {
settings = {
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
};
return { ...settings };
}

278
src/lib/services/sync.ts Normal file
View File

@@ -0,0 +1,278 @@
import type { Workspace, WorkspaceSyncGroup } from "$lib/types/workspace";
import type { Collection } from "$lib/types/collection";
import type { Request } from "$lib/types/request";
import type { Variable } from "$lib/types/variable";
import {
get_collections_by_workspace,
get_standalone_requests_by_workspace,
create_collection,
create_request,
} from "./collections";
import {
get_workspace_variables,
create_variable,
update_variable,
get_variables_by_scope,
} from "./variables";
const syncGroups: Map<string, WorkspaceSyncGroup> = new Map();
export async function get_sync_groups(): Promise<WorkspaceSyncGroup[]> {
return [...syncGroups.values()];
}
export async function get_sync_group(
id: string
): Promise<WorkspaceSyncGroup | null> {
return syncGroups.get(id) ?? null;
}
export async function get_sync_group_for_workspace(
workspaceId: string
): Promise<WorkspaceSyncGroup | null> {
for (const group of syncGroups.values()) {
if (group.workspaceIds.includes(workspaceId)) {
return group;
}
}
return null;
}
export async function create_sync_group(
name: string,
workspaceIds: string[],
syncedVariableNames: string[] = [],
syncSecrets: boolean = false
): Promise<WorkspaceSyncGroup> {
const group: WorkspaceSyncGroup = {
id: crypto.randomUUID(),
name,
workspaceIds,
syncedVariableNames,
syncSecrets,
};
syncGroups.set(group.id, group);
return group;
}
export async function update_sync_group(
id: string,
updates: Partial<Omit<WorkspaceSyncGroup, "id">>
): Promise<boolean> {
const group = syncGroups.get(id);
if (!group) return false;
Object.assign(group, updates);
return true;
}
export async function delete_sync_group(id: string): Promise<boolean> {
return syncGroups.delete(id);
}
export async function add_workspace_to_sync_group(
groupId: string,
workspaceId: string
): Promise<boolean> {
const group = syncGroups.get(groupId);
if (!group) return false;
if (!group.workspaceIds.includes(workspaceId)) {
group.workspaceIds.push(workspaceId);
}
return true;
}
export async function remove_workspace_from_sync_group(
groupId: string,
workspaceId: string
): Promise<boolean> {
const group = syncGroups.get(groupId);
if (!group) return false;
group.workspaceIds = group.workspaceIds.filter((id) => id !== workspaceId);
return true;
}
export type DuplicateWorkspaceOptions = {
sourceWorkspaceId: string;
newName: string;
newDescription: string;
environment?: string;
copyVariables: boolean;
copySecrets: boolean;
createSyncGroup: boolean;
syncGroupName?: string;
variablesToSync?: string[];
};
export type DuplicateWorkspaceResult = {
workspace: Workspace;
syncGroup?: WorkspaceSyncGroup;
};
export async function duplicate_workspace(
options: DuplicateWorkspaceOptions,
createWorkspaceFn: (workspace: Omit<Workspace, "Id">) => Promise<Workspace>
): Promise<DuplicateWorkspaceResult> {
const {
sourceWorkspaceId,
newName,
newDescription,
environment,
copyVariables,
copySecrets,
createSyncGroup: shouldCreateSyncGroup,
syncGroupName,
variablesToSync = [],
} = options;
// Create the new workspace
const newWorkspace = await createWorkspaceFn({
Name: newName,
Description: newDescription,
environment,
syncGroupId: null,
});
// Copy collections and requests
const collections = await get_collections_by_workspace(sourceWorkspaceId);
const collectionIdMap = new Map<string, string>();
for (const collection of collections) {
const newCollection = await create_collection({
name: collection.name,
description: collection.description,
workspaceId: newWorkspace.Id,
});
collectionIdMap.set(collection.id, newCollection.id);
// Copy requests in collection
for (const request of collection.requests) {
await create_request({
name: request.name,
method: request.method,
url: request.url,
headers: [...request.headers],
params: [...request.params],
bodyType: request.bodyType,
body: request.body,
formData: [...request.formData],
collectionId: newCollection.id,
workspaceId: newWorkspace.Id,
});
}
}
// Copy standalone requests
const standaloneRequests = await get_standalone_requests_by_workspace(
sourceWorkspaceId
);
for (const request of standaloneRequests) {
await create_request({
name: request.name,
method: request.method,
url: request.url,
headers: [...request.headers],
params: [...request.params],
bodyType: request.bodyType,
body: request.body,
formData: [...request.formData],
collectionId: null,
workspaceId: newWorkspace.Id,
});
}
// Copy variables
if (copyVariables) {
const sourceVariables = await get_workspace_variables(sourceWorkspaceId);
for (const variable of sourceVariables) {
// Skip secrets if not copying them
if (variable.isSecret && !copySecrets) {
// Create variable with empty value for secrets
await create_variable({
name: variable.name,
value: "",
scope: "workspace",
scopeId: newWorkspace.Id,
isSecret: true,
description: variable.description,
});
} else {
await create_variable({
name: variable.name,
value: variable.value,
scope: "workspace",
scopeId: newWorkspace.Id,
isSecret: variable.isSecret,
description: variable.description,
});
}
}
}
// Create sync group if requested
let syncGroup: WorkspaceSyncGroup | undefined;
if (shouldCreateSyncGroup) {
syncGroup = await create_sync_group(
syncGroupName || `${newName} Sync Group`,
[sourceWorkspaceId, newWorkspace.Id],
variablesToSync,
copySecrets
);
newWorkspace.syncGroupId = syncGroup.id;
}
return { workspace: newWorkspace, syncGroup };
}
export async function sync_variables_in_group(
groupId: string,
sourceWorkspaceId: string
): Promise<{ synced: number; skipped: number }> {
const group = syncGroups.get(groupId);
if (!group) return { synced: 0, skipped: 0 };
const sourceVariables = await get_workspace_variables(sourceWorkspaceId);
let synced = 0;
let skipped = 0;
for (const targetWorkspaceId of group.workspaceIds) {
if (targetWorkspaceId === sourceWorkspaceId) continue;
const targetVariables = await get_workspace_variables(targetWorkspaceId);
const targetVarMap = new Map(targetVariables.map((v) => [v.name, v]));
for (const sourceVar of sourceVariables) {
// Only sync variables that are in the sync list
if (!group.syncedVariableNames.includes(sourceVar.name)) {
skipped++;
continue;
}
// Skip secrets if not syncing them
if (sourceVar.isSecret && !group.syncSecrets) {
skipped++;
continue;
}
const targetVar = targetVarMap.get(sourceVar.name);
if (targetVar) {
// Update existing variable
await update_variable(targetVar.id, { value: sourceVar.value });
synced++;
} else {
// Create new variable in target workspace
await create_variable({
name: sourceVar.name,
value:
sourceVar.isSecret && !group.syncSecrets ? "" : sourceVar.value,
scope: "workspace",
scopeId: targetWorkspaceId,
isSecret: sourceVar.isSecret,
description: sourceVar.description,
});
synced++;
}
}
}
return { synced, skipped };
}

View File

@@ -7,6 +7,8 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
Id: "1",
Name: "Test 1",
Description: "This is a test description",
environment: "Development",
syncGroupId: null,
},
],
[
@@ -15,6 +17,8 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
Id: "2",
Name: "Test 2",
Description: "This is a longer test description",
environment: "Production",
syncGroupId: null,
},
],
[
@@ -23,6 +27,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
Id: "3",
Name: "Test 3",
Description: "This is a slightly longer test description",
syncGroupId: null,
},
],
[
@@ -31,6 +36,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
Id: "4",
Name: "Test 4",
Description: "This is an even slightly longer test description",
syncGroupId: null,
},
],
[
@@ -40,6 +46,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
Name: "Test 5",
Description:
"This is a veryyyyyyyyyyyyyyyyyyyyyyyyyyy longggggggggggggggggggggggggggg test descriptionnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
syncGroupId: null,
},
],
]);
@@ -67,8 +74,23 @@ export async function update_workspace(workspace: Workspace): Promise<boolean> {
return true;
}
export async function create_workspace(workspace: Workspace): Promise<boolean> {
workspace.Id = crypto.randomUUID();
ws.set(workspace.Id, workspace);
return true;
export async function create_workspace(
workspace: Omit<Workspace, "Id">
): Promise<Workspace> {
const newWorkspace: Workspace = {
...workspace,
Id: crypto.randomUUID(),
};
ws.set(newWorkspace.Id, newWorkspace);
return newWorkspace;
}
export async function delete_workspace(id: string): Promise<boolean> {
return ws.delete(id);
}
export async function get_workspaces_by_sync_group(
syncGroupId: string
): Promise<Workspace[]> {
return [...ws.values()].filter((w) => w.syncGroupId === syncGroupId);
}

View File

@@ -2,4 +2,23 @@ export type Workspace = {
Id: string;
Name: string;
Description: string;
syncGroupId?: string | null;
environment?: string;
};
export type WorkspaceSyncGroup = {
id: string;
name: string;
workspaceIds: string[];
syncedVariableNames: string[];
syncSecrets: boolean;
};
export type AppSettings = {
theme: "light" | "dark" | "system";
defaultTimeout: number;
followRedirects: boolean;
validateSSL: boolean;
maxHistoryItems: number;
autoSaveRequests: boolean;
};

View File

@@ -1,29 +1,48 @@
<script lang="ts">
import * as Empty from "$lib/components/ui/empty/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import FolderCodeIcon from "@lucide/svelte/icons/folder-code";
import SettingsIcon from "@lucide/svelte/icons/settings";
import VariableIcon from "@lucide/svelte/icons/variable";
import MoreVerticalIcon from "@lucide/svelte/icons/more-vertical";
import CopyIcon from "@lucide/svelte/icons/copy";
import PencilIcon from "@lucide/svelte/icons/pencil";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import {
create_workspace,
get_workspaces,
update_workspace,
delete_workspace,
} from "$lib/services/workspaces";
import {
duplicate_workspace,
type DuplicateWorkspaceOptions,
} from "$lib/services/sync";
import type { Workspace } from "$lib/types/workspace";
import * as Card from "$lib/components/ui/card/index";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { goto } from "$app/navigation";
import SettingsDialog from "$lib/components/settings-dialog.svelte";
import VariablesPanel from "$lib/components/variables-panel.svelte";
import DuplicateWorkspaceDialog from "$lib/components/duplicate-workspace-dialog.svelte";
$effect(() => {
get_workspaces().then((ws) => {
loadWorkspaces();
});
async function loadWorkspaces() {
const ws = await get_workspaces();
if (ws.length == 0) {
showPrompt = true;
} else {
console.log(ws);
workspaces = ws;
showPrompt = false;
}
}
});
});
let showPrompt = $state(false);
let workspaces = $state<Workspace[]>([]);
@@ -33,6 +52,13 @@
let workspaceName = $state("");
let workspaceDescription = $state("");
let settingsOpen = $state(false);
let variablesOpen = $state(false);
let duplicateDialogOpen = $state(false);
let workspaceToDuplicate = $state<Workspace | null>(null);
let deleteDialogOpen = $state(false);
let workspaceToDelete = $state<Workspace | null>(null);
function openCreateDialog() {
dialogMode = "create";
selectedWorkspace = null;
@@ -49,37 +75,54 @@
dialogOpen = true;
}
function openDuplicateDialog(workspace: Workspace) {
workspaceToDuplicate = workspace;
duplicateDialogOpen = true;
}
function openDeleteDialog(workspace: Workspace) {
workspaceToDelete = workspace;
deleteDialogOpen = true;
}
function handleWorkspaceClick(id: string) {
goto(`/workspaces/${id}`)
goto(`/workspaces/${id}`);
}
async function handleDialogSubmit() {
let success = false;
if (dialogMode === "create") {
const w: Workspace = {
// Id will be assigned in create_workspace
Id: "",
await create_workspace({
Name: workspaceName,
Description: workspaceDescription,
};
success = await create_workspace(w);
});
} else if (selectedWorkspace != null) {
const w: Workspace = {
Id: selectedWorkspace.Id,
Name: workspaceName,
Description: workspaceDescription,
};
success = await update_workspace(w);
}
if (success) {
const updated = await get_workspaces();
workspaces = updated;
showPrompt = updated.length === 0;
await update_workspace(w);
}
await loadWorkspaces();
dialogOpen = false;
}
async function handleDuplicate(options: DuplicateWorkspaceOptions) {
await duplicate_workspace(options, async (ws) => {
return await create_workspace(ws);
});
await loadWorkspaces();
}
async function handleDeleteConfirm() {
if (workspaceToDelete) {
await delete_workspace(workspaceToDelete.Id);
await loadWorkspaces();
}
deleteDialogOpen = false;
workspaceToDelete = null;
}
</script>
<Dialog.Root bind:open={dialogOpen}>
@@ -107,8 +150,26 @@
<div class="min-h-[calc(100vh-4rem)] p-6 space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Workspaces</h1>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onclick={() => (variablesOpen = true)}
aria-label="Global Variables"
>
<VariableIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onclick={() => (settingsOpen = true)}
aria-label="Settings"
>
<SettingsIcon class="size-4" />
</Button>
<Button onclick={openCreateDialog}>Create Workspace</Button>
</div>
</div>
<div
class="grid gap-6
grid-cols-1
@@ -119,22 +180,69 @@
>
{#each workspaces as workspace (workspace.Id)}
<Card.Root
class="min-h-40 w-full max-w-xs mx-auto flex flex-col justify-between cursor-pointer hover:shadow-md transition-shadow"
class="min-h-40 w-full max-w-xs mx-auto flex flex-col justify-between hover:shadow-md transition-shadow group"
>
<Card.Header>
<Card.Title class="truncate">{workspace.Name}</Card.Title>
<Card.Description class="text-xs text-muted-foreground">
{workspace.Description}
</Card.Description>
</Card.Header>
<Card.Footer class="flex items-center justify-center gap-2">
<Button size="sm" class="justify-center" onclick={() => handleWorkspaceClick(workspace.Id)}>Open</Button>
<Card.Header class="relative">
<div
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
size="sm"
class="justify-center"
variant="ghost"
size="icon"
class="h-8 w-8"
{...props}
aria-label="Workspace options"
>
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => openEditDialog(workspace)}
>
<PencilIcon class="size-4 mr-2" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => openDuplicateDialog(workspace)}
>
<CopyIcon class="size-4 mr-2" />
Duplicate
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive"
onclick={() => openDeleteDialog(workspace)}
>
<TrashIcon class="size-4 mr-2" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<Card.Title class="truncate pr-8">{workspace.Name}</Card.Title>
<Card.Description
class="text-xs text-muted-foreground line-clamp-2"
>
{workspace.Description}
</Card.Description>
{#if workspace.environment}
<Badge variant="secondary" class="w-fit mt-2"
>{workspace.environment}</Badge
>
{/if}
</Card.Header>
<Card.Footer class="flex items-center justify-center gap-2">
<Button
size="sm"
class="justify-center flex-1"
onclick={() => handleWorkspaceClick(workspace.Id)}
>
Open
</Button>
</Card.Footer>
</Card.Root>
@@ -181,3 +289,40 @@
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={deleteDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Delete Workspace</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete "{workspaceToDelete?.Name}"? This will
permanently delete all collections, requests, and variables in this
workspace.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}
>Cancel</Button
>
<Button variant="destructive" onclick={handleDeleteConfirm}>Delete</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<SettingsDialog
bind:open={settingsOpen}
onOpenChange={(v) => (settingsOpen = v)}
/>
<VariablesPanel
bind:open={variablesOpen}
onOpenChange={(v) => (variablesOpen = v)}
/>
<DuplicateWorkspaceDialog
bind:open={duplicateDialogOpen}
onOpenChange={(v) => (duplicateDialogOpen = v)}
sourceWorkspace={workspaceToDuplicate}
onDuplicate={handleDuplicate}
/>

View File

@@ -10,7 +10,11 @@
import RequestPanel from "$lib/components/request-panel.svelte";
import * as Empty from "$lib/components/ui/empty/index.js";
import SendIcon from "@lucide/svelte/icons/send";
import VariableIcon from "@lucide/svelte/icons/variable";
import SettingsIcon from "@lucide/svelte/icons/settings";
import { get_workspace } from "$lib/services/workspaces";
import VariablesPanel from "$lib/components/variables-panel.svelte";
import SettingsDialog from "$lib/components/settings-dialog.svelte";
import {
get_collections_by_workspace,
get_standalone_requests_by_workspace,
@@ -39,6 +43,9 @@
let loading = $state(false);
let resolvedVariables = $state<ResolvedVariable[]>([]);
let variablesOpen = $state(false);
let settingsOpen = $state(false);
let collectionDialogOpen = $state(false);
let collectionDialogMode = $state<"create" | "edit">("create");
let editingCollection = $state<Collection | null>(null);
@@ -280,6 +287,24 @@
</h1>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onclick={() => (variablesOpen = true)}
aria-label="Variables"
>
<VariableIcon class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onclick={() => (settingsOpen = true)}
aria-label="Settings"
>
<SettingsIcon class="size-4" />
</Button>
</div>
</header>
<div class="flex-1 overflow-hidden flex flex-col">
@@ -412,3 +437,15 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<VariablesPanel
bind:open={variablesOpen}
onOpenChange={(v) => (variablesOpen = v)}
workspaceId={params.id}
collectionId={selectedRequest?.collectionId ?? undefined}
/>
<SettingsDialog
bind:open={settingsOpen}
onOpenChange={(v) => (settingsOpen = v)}
/>