wrap inputs in real forms, so enter submits instead of doing nothing
24 panel and screen files had their fields sitting in a <div> instead of a <form> — typing a value and pressing Enter did nothing, and the button was the only way in. Fixed the sites that are a real form, a single field with an obvious action, or a multi-field editor; left search/filter boxes and a few genuinely ambiguous sites alone (see TODO.md for the full breakdown). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -344,7 +345,8 @@ export const CapabilityList = ({
|
|||||||
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
@@ -382,16 +384,15 @@ export const CapabilityList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{creating && (
|
{creating && (
|
||||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
|
<form
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={newNameRef}
|
ref={newNameRef}
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(ev) => setNewName(ev.target.value)}
|
onChange={(ev) => setNewName(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleCreate();
|
|
||||||
}
|
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
@@ -401,13 +402,14 @@ export const CapabilityList = ({
|
|||||||
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
type="submit"
|
||||||
disabled={!newName.trim()}
|
disabled={!newName.trim()}
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
@@ -416,7 +418,7 @@ export const CapabilityList = ({
|
|||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
{externalSearch === undefined && (
|
{externalSearch === undefined && (
|
||||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -61,7 +62,8 @@ export const ApiKeys = () => {
|
|||||||
|
|
||||||
const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||||
|
|
||||||
const create = async () => {
|
const create = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed || isCreating) return;
|
if (!trimmed || isCreating) return;
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
@@ -130,22 +132,21 @@ export const ApiKeys = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<form onSubmit={create} className="flex items-end gap-2">
|
||||||
<Label className="grid flex-1 gap-2">
|
<Label className="grid flex-1 gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20"
|
className="h-11 bg-background/60 border-duck-dark/20"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
onKeyDown={(ev) => ev.key === 'Enter' && create()}
|
|
||||||
placeholder="iPhone, Music app, laptop CLI…"
|
placeholder="iPhone, Music app, laptop CLI…"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Button onClick={create} disabled={!name.trim() || isCreating} className="h-11">
|
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
|||||||
+6
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -31,7 +32,8 @@ export const ApifyConfig = () => {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +62,7 @@ export const ApifyConfig = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-5">
|
<form onSubmit={handleSave} className="grid gap-5">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -84,14 +86,13 @@ export const ApifyConfig = () => {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving || !apiToken.trim()}
|
disabled={isSaving || !apiToken.trim()}
|
||||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
|
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
|
||||||
@@ -59,7 +60,8 @@ export const DavAppPasswords = () => {
|
|||||||
|
|
||||||
useEffect(load, [load]);
|
useEffect(load, [load]);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const trimmed = label.trim();
|
const trimmed = label.trim();
|
||||||
if (!trimmed || isCreating) return;
|
if (!trimmed || isCreating) return;
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
@@ -142,22 +144,21 @@ export const DavAppPasswords = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<form onSubmit={create} className="flex items-end gap-2">
|
||||||
<Label className="grid flex-1 gap-2">
|
<Label className="grid flex-1 gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20"
|
className="h-11 bg-background/60 border-duck-dark/20"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(ev) => setLabel(ev.target.value)}
|
onChange={(ev) => setLabel(ev.target.value)}
|
||||||
onKeyDown={(ev) => ev.key === 'Enter' && create()}
|
|
||||||
placeholder="Pixel 9, iPad, Thunderbird…"
|
placeholder="Pixel 9, iPad, Thunderbird…"
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Button onClick={create} disabled={!label.trim() || isCreating} className="h-11">
|
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{passwords.length === 0 ? (
|
{passwords.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
|||||||
+6
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
@@ -266,7 +267,8 @@ export const GoogleOAuthConfig = () => {
|
|||||||
if (!isLoading && clientId && clientSecret) verify();
|
if (!isLoading && clientId && clientSecret) verify();
|
||||||
}, [isLoading]);
|
}, [isLoading]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -290,7 +292,7 @@ export const GoogleOAuthConfig = () => {
|
|||||||
|
|
||||||
<SetupGuide redirectUri={redirectUri} />
|
<SetupGuide redirectUri={redirectUri} />
|
||||||
|
|
||||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -327,14 +329,13 @@ export const GoogleOAuthConfig = () => {
|
|||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving || !clientId.trim() || !clientSecret.trim()}
|
disabled={isSaving || !clientId.trim() || !clientSecret.trim()}
|
||||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+82
-68
@@ -226,6 +226,23 @@ export const AIHarnessesSection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitLocalUrl = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
|
if (probeState.step === 'url' && localUrl.trim()) handleProbe();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAuth = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
|
if (probeState.step !== 'auth') return;
|
||||||
|
const ready = probeState.probe.authType === 'basic' ? authUsername && authPassword : authApiKey;
|
||||||
|
if (ready) handleAuthSubmit(probeState.probe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitApiKey = (ev: React.FormEvent, providerId: string) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (keyInputs[providerId]) saveApiKey(providerId);
|
||||||
|
};
|
||||||
|
|
||||||
const removeLocalProvider = async (id: string) => {
|
const removeLocalProvider = async (id: string) => {
|
||||||
await client.delete(`/server-settings/chat-providers/local-providers/${id}`);
|
await client.delete(`/server-settings/chat-providers/local-providers/${id}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
|
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
|
||||||
@@ -291,57 +308,60 @@ export const AIHarnessesSection = () => {
|
|||||||
{addingLocal ? (
|
{addingLocal ? (
|
||||||
<div className="flex flex-col gap-2 max-w-sm">
|
<div className="flex flex-col gap-2 max-w-sm">
|
||||||
{/* Step 1: Name + URL inputs stacked */}
|
{/* Step 1: Name + URL inputs stacked */}
|
||||||
<Input
|
<form onSubmit={submitLocalUrl} className="contents">
|
||||||
type="text"
|
<Input
|
||||||
className="h-8 text-xs"
|
type="text"
|
||||||
placeholder="Name (e.g. My Ollama)"
|
className="h-8 text-xs"
|
||||||
value={localName}
|
placeholder="Name (e.g. My Ollama)"
|
||||||
onChange={(ev) => setLocalName(ev.target.value)}
|
value={localName}
|
||||||
onKeyDown={(ev) => {
|
onChange={(ev) => setLocalName(ev.target.value)}
|
||||||
if (ev.key === 'Escape') resetLocalForm();
|
onKeyDown={(ev) => {
|
||||||
}}
|
if (ev.key === 'Escape') resetLocalForm();
|
||||||
disabled={probeState.step !== 'url'}
|
}}
|
||||||
autoFocus
|
disabled={probeState.step !== 'url'}
|
||||||
/>
|
autoFocus
|
||||||
<Input
|
/>
|
||||||
type="url"
|
<Input
|
||||||
className="h-8 text-xs"
|
type="url"
|
||||||
placeholder="http://localhost:11434"
|
className="h-8 text-xs"
|
||||||
value={localUrl}
|
placeholder="http://localhost:11434"
|
||||||
onChange={(ev) => setLocalUrl(ev.target.value)}
|
value={localUrl}
|
||||||
onKeyDown={(ev) => {
|
onChange={(ev) => setLocalUrl(ev.target.value)}
|
||||||
if (ev.key === 'Escape') resetLocalForm();
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
|
if (ev.key === 'Escape') resetLocalForm();
|
||||||
}}
|
}}
|
||||||
disabled={probeState.step !== 'url'}
|
disabled={probeState.step !== 'url'}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{probeState.step === 'url' && (
|
{probeState.step === 'url' && (
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||||
|
disabled={!localUrl.trim()}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(probeState.step === 'probing' || probeState.step === 'saving') && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
onClick={resetLocalForm}
|
||||||
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||||
disabled={!localUrl.trim()}
|
|
||||||
onClick={() => handleProbe()}
|
|
||||||
>
|
>
|
||||||
Connect
|
Cancel
|
||||||
</Button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
{(probeState.step === 'probing' || probeState.step === 'saving') && (
|
</form>
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetLocalForm}
|
|
||||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 2: Auth form (if needed) */}
|
{/* Step 2: Auth form (if needed) */}
|
||||||
{probeState.step === 'auth' && (
|
{probeState.step === 'auth' && (
|
||||||
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
|
<form
|
||||||
|
onSubmit={submitAuth}
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||||
|
>
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
|
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
|
||||||
{probeState.probe.name} requires authentication
|
{probeState.probe.name} requires authentication
|
||||||
</span>
|
</span>
|
||||||
@@ -361,17 +381,12 @@ export const AIHarnessesSection = () => {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
value={authPassword}
|
value={authPassword}
|
||||||
onChange={(ev) => setAuthPassword(ev.target.value)}
|
onChange={(ev) => setAuthPassword(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter' && authUsername && authPassword)
|
|
||||||
handleAuthSubmit(probeState.probe);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||||
disabled={!authUsername || !authPassword}
|
disabled={!authUsername || !authPassword}
|
||||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
|
||||||
>
|
>
|
||||||
Authenticate
|
Authenticate
|
||||||
</Button>
|
</Button>
|
||||||
@@ -384,23 +399,19 @@ export const AIHarnessesSection = () => {
|
|||||||
placeholder="API Key"
|
placeholder="API Key"
|
||||||
value={authApiKey}
|
value={authApiKey}
|
||||||
onChange={(ev) => setAuthApiKey(ev.target.value)}
|
onChange={(ev) => setAuthApiKey(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||||
disabled={!authApiKey}
|
disabled={!authApiKey}
|
||||||
onClick={() => handleAuthSubmit(probeState.probe)}
|
|
||||||
>
|
>
|
||||||
Authenticate
|
Authenticate
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -460,7 +471,10 @@ export const AIHarnessesSection = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{editingProvider === provider.key && (
|
{editingProvider === provider.key && (
|
||||||
<div className="flex items-center gap-2 pl-4">
|
<form
|
||||||
|
onSubmit={(ev) => submitApiKey(ev, provider.providerId)}
|
||||||
|
className="flex items-center gap-2 pl-4"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
className="h-7 text-xs flex-1"
|
className="h-7 text-xs flex-1"
|
||||||
@@ -470,15 +484,13 @@ export const AIHarnessesSection = () => {
|
|||||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||||
}
|
}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
|
||||||
if (ev.key === 'Escape') setEditingProvider(null);
|
if (ev.key === 'Escape') setEditingProvider(null);
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
||||||
onClick={() => saveApiKey(provider.providerId)}
|
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||||
title="Save key"
|
title="Save key"
|
||||||
>
|
>
|
||||||
@@ -491,7 +503,7 @@ export const AIHarnessesSection = () => {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -501,7 +513,11 @@ export const AIHarnessesSection = () => {
|
|||||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
|
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
|
||||||
return null;
|
return null;
|
||||||
return (
|
return (
|
||||||
<div key={provider.providerId} className="flex items-center gap-2">
|
<form
|
||||||
|
key={provider.providerId}
|
||||||
|
onSubmit={(ev) => submitApiKey(ev, provider.providerId)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
||||||
{provider.key}
|
{provider.key}
|
||||||
</label>
|
</label>
|
||||||
@@ -514,15 +530,13 @@ export const AIHarnessesSection = () => {
|
|||||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||||
}
|
}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
|
||||||
if (ev.key === 'Escape') setEditingProvider(null);
|
if (ev.key === 'Escape') setEditingProvider(null);
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
||||||
onClick={() => saveApiKey(provider.providerId)}
|
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||||
title="Save key"
|
title="Save key"
|
||||||
>
|
>
|
||||||
@@ -535,7 +549,7 @@ export const AIHarnessesSection = () => {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{unconnectedProviders.length > 0 && (
|
{unconnectedProviders.length > 0 && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -51,7 +52,8 @@ export const OCRSection = () => {
|
|||||||
fetchModels(config.url);
|
fetchModels(config.url);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +94,7 @@ export const OCRSection = () => {
|
|||||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<form onSubmit={handleSave} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -143,8 +145,7 @@ export const OCRSection = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
@@ -160,6 +161,6 @@ export const OCRSection = () => {
|
|||||||
{isTesting ? 'Testing...' : 'Test'}
|
{isTesting ? 'Testing...' : 'Test'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -84,7 +85,8 @@ export const SMTPSection = () => {
|
|||||||
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
|
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -118,7 +120,8 @@ export const SMTPSection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isTesting || !testEmail) return;
|
if (isTesting || !testEmail) return;
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
try {
|
try {
|
||||||
@@ -151,150 +154,147 @@ export const SMTPSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<form onSubmit={handleSave} className="grid gap-4">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
|
||||||
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
|
||||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="z-[600]">
|
|
||||||
<SelectItem value="mailhog">MailHog (local testing)</SelectItem>
|
|
||||||
<SelectItem value="resend">Resend</SelectItem>
|
|
||||||
<SelectItem value="smtp">Custom SMTP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{provider === 'resend' && (
|
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
||||||
<Input
|
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
||||||
type="password"
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
<SelectValue />
|
||||||
placeholder="re_..."
|
</SelectTrigger>
|
||||||
value={apiKey}
|
<SelectContent className="z-[600]">
|
||||||
onChange={(ev) => setApiKey(ev.target.value)}
|
<SelectItem value="mailhog">MailHog (local testing)</SelectItem>
|
||||||
/>
|
<SelectItem value="resend">Resend</SelectItem>
|
||||||
|
<SelectItem value="smtp">Custom SMTP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
|
|
||||||
{showSmtpFields && (
|
{provider === 'resend' && (
|
||||||
<>
|
<Label className="grid gap-2">
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
|
||||||
<Label className="grid gap-2">
|
<Input
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
|
type="password"
|
||||||
<Input
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
placeholder="re_..."
|
||||||
placeholder="smtp.example.com"
|
value={apiKey}
|
||||||
value={host}
|
onChange={(ev) => setApiKey(ev.target.value)}
|
||||||
onChange={(ev) => setHost(ev.target.value)}
|
/>
|
||||||
/>
|
</Label>
|
||||||
</Label>
|
)}
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
|
{showSmtpFields && (
|
||||||
<Input
|
<>
|
||||||
type="number"
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
<Label className="grid gap-2">
|
||||||
value={port}
|
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
|
||||||
onChange={(ev) => setPort(ev.target.value)}
|
<Input
|
||||||
/>
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||||
</Label>
|
placeholder="smtp.example.com"
|
||||||
</div>
|
value={host}
|
||||||
{provider === 'smtp' && (
|
onChange={(ev) => setHost(ev.target.value)}
|
||||||
<>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
|
||||||
placeholder="user@example.com"
|
|
||||||
value={username}
|
|
||||||
onChange={(ev) => setUsername(ev.target.value)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
|
||||||
value={password}
|
|
||||||
onChange={(ev) => setPassword(ev.target.value)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Label className="flex items-center gap-3">
|
|
||||||
<Switch checked={secure} onCheckedChange={setSecure} />
|
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
</>
|
<Label className="grid gap-2">
|
||||||
)}
|
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
|
||||||
</>
|
<Input
|
||||||
)}
|
type="number"
|
||||||
|
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
||||||
|
value={port}
|
||||||
|
onChange={(ev) => setPort(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{provider === 'smtp' && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={username}
|
||||||
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Label className="flex items-center gap-3">
|
||||||
|
<Switch checked={secure} onCheckedChange={setSecure} />
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
|
||||||
|
</Label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
|
||||||
<Input
|
<Input
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||||
value={fromName}
|
value={fromName}
|
||||||
onChange={(ev) => setFromName(ev.target.value)}
|
onChange={(ev) => setFromName(ev.target.value)}
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||||
value={fromEmail}
|
value={fromEmail}
|
||||||
onChange={(ev) => setFromEmail(ev.target.value)}
|
onChange={(ev) => setFromEmail(ev.target.value)}
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
disabled={isSaving}
|
||||||
disabled={isSaving}
|
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
>
|
||||||
>
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={handleTestConnection}
|
||||||
onClick={handleTestConnection}
|
disabled={isTestingConnection}
|
||||||
disabled={isTestingConnection}
|
className="h-11 cursor-pointer disabled:opacity-50"
|
||||||
className="h-11 cursor-pointer disabled:opacity-50"
|
>
|
||||||
>
|
{isTestingConnection ? 'Testing...' : 'Test'}
|
||||||
{isTestingConnection ? 'Testing...' : 'Test'}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
|
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
|
||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
|
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
value={testEmail}
|
value={testEmail}
|
||||||
onChange={(ev) => setTestEmail(ev.target.value)}
|
onChange={(ev) => setTestEmail(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') handleTest();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleTest}
|
|
||||||
disabled={isTesting || !testEmail}
|
disabled={isTesting || !testEmail}
|
||||||
className="h-9 cursor-pointer disabled:opacity-50"
|
className="h-9 cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isTesting ? 'Sending...' : 'Send'}
|
{isTesting ? 'Sending...' : 'Send'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -28,7 +29,8 @@ export const STTSection = () => {
|
|||||||
setUrl(config.url);
|
setUrl(config.url);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +71,7 @@ export const STTSection = () => {
|
|||||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<form onSubmit={handleSave} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -82,8 +84,7 @@ export const STTSection = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
@@ -99,6 +100,6 @@ export const STTSection = () => {
|
|||||||
{isTesting ? 'Testing...' : 'Test'}
|
{isTesting ? 'Testing...' : 'Test'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -102,7 +103,8 @@ export const TTSSection = () => {
|
|||||||
...(apiKey ? { apiKey } : {}),
|
...(apiKey ? { apiKey } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +154,7 @@ export const TTSSection = () => {
|
|||||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<form onSubmit={handleSave} className="grid gap-4">
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
||||||
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
||||||
@@ -255,8 +257,7 @@ export const TTSSection = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
className="flex-1 h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
>
|
>
|
||||||
@@ -272,6 +273,6 @@ export const TTSSection = () => {
|
|||||||
{isTesting ? 'Testing...' : 'Test'}
|
{isTesting ? 'Testing...' : 'Test'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export const AppStoreDetail = () => {
|
|||||||
const fields = chosen === 'existing' ? (item.existingFields ?? []) : (item.configFields ?? []);
|
const fields = chosen === 'existing' ? (item.existingFields ?? []) : (item.configFields ?? []);
|
||||||
const isInstalled = item.install.status === 'installed' || item.install.status === 'blocked';
|
const isInstalled = item.install.status === 'installed' || item.install.status === 'blocked';
|
||||||
|
|
||||||
const run = async (replace = false) => {
|
const run = async (ev?: React.FormEvent, replace = false) => {
|
||||||
|
ev?.preventDefault();
|
||||||
setLog([]);
|
setLog([]);
|
||||||
const result = await install.mutateAsync({
|
const result = await install.mutateAsync({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -118,7 +119,8 @@ export const AppStoreDetail = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
||||||
|
<form onSubmit={run} className="flex flex-col gap-3">
|
||||||
{item.modes.length > 1 && (
|
{item.modes.length > 1 && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{item.modes.map((m) => (
|
{item.modes.map((m) => (
|
||||||
@@ -142,11 +144,11 @@ export const AppStoreDetail = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button className="self-start" disabled={install.isPending} onClick={() => run()}>
|
<Button type="submit" className="self-start" disabled={install.isPending}>
|
||||||
{install.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{install.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Install
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The install log. Streaming it into a terminal panel is the intended shape; until then it is
|
{/* The install log. Streaming it into a terminal panel is the intended shape; until then it is
|
||||||
|
|||||||
@@ -89,24 +89,31 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
renaming === s.id ? (
|
renaming === s.id ? (
|
||||||
// Inline, because the alternative is a modal over a panel header — and the only thing being
|
// Inline, because the alternative is a modal over a panel header — and the only thing being
|
||||||
// asked for is one line of text.
|
// asked for is one line of text.
|
||||||
<div key={s.id} className="flex items-center gap-1 px-2 py-1.5" onClick={(ev) => ev.stopPropagation()}>
|
<form
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-1 px-2 py-1.5"
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void commitRename(s.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') void commitRename(s.id);
|
|
||||||
if (ev.key === 'Escape') setRenaming(null);
|
if (ev.key === 'Escape') setRenaming(null);
|
||||||
}}
|
}}
|
||||||
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => void commitRename(s.id)} className="p-0.5 cursor-pointer" title="Save">
|
<button type="submit" className="p-0.5 cursor-pointer" title="Save">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setRenaming(null)} className="p-0.5 cursor-pointer" title="Cancel">
|
<button type="button" onClick={() => setRenaming(null)} className="p-0.5 cursor-pointer" title="Cancel">
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CirclePlay } from 'lucide-react';
|
import { CirclePlay } from 'lucide-react';
|
||||||
import { toneText } from '@/components/Data';
|
import { toneText } from '@/components/Data';
|
||||||
@@ -91,7 +92,8 @@ export const BackgroundJobDialog = ({
|
|||||||
// the job, so it is required.
|
// the job, so it is required.
|
||||||
const canSubmit = watching ? condition.trim().length > 0 : body.trim().length > 0;
|
const canSubmit = watching ? condition.trim().length > 0 : body.trim().length > 0;
|
||||||
|
|
||||||
const submit = () => {
|
const submit = (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
const spec: BackgroundJobSpec = {
|
const spec: BackgroundJobSpec = {
|
||||||
mode,
|
mode,
|
||||||
@@ -123,7 +125,7 @@ export const BackgroundJobDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
@@ -202,16 +204,16 @@ export const BackgroundJobDialog = ({
|
|||||||
{timed ? 'Exits when the time is up, whether or not it fired.' : 'Runs until you stop it.'}
|
{timed ? 'Exits when the time is up, whether or not it fired.' : 'Runs until you stop it.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={!canSubmit}>
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,31 +163,29 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
|||||||
{/* "Other" — provided by the client on purpose. Both schemas say the model must NOT offer one
|
{/* "Other" — provided by the client on purpose. Both schemas say the model must NOT offer one
|
||||||
as an option, precisely because the escape hatch is the UI's job. */}
|
as an option, precisely because the escape hatch is the UI's job. */}
|
||||||
{!done && (
|
{!done && (
|
||||||
<div className="flex gap-2">
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const text = (otherText[qi] ?? '').trim();
|
||||||
|
if (text) record(qi, text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={otherText[qi] ?? ''}
|
value={otherText[qi] ?? ''}
|
||||||
onChange={(ev) => setOtherText((prev) => ({ ...prev, [qi]: ev.target.value }))}
|
onChange={(ev) => setOtherText((prev) => ({ ...prev, [qi]: ev.target.value }))}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key !== 'Enter') return;
|
|
||||||
ev.preventDefault();
|
|
||||||
const text = (otherText[qi] ?? '').trim();
|
|
||||||
if (text) record(qi, text);
|
|
||||||
}}
|
|
||||||
placeholder="Other..."
|
placeholder="Other..."
|
||||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="submit"
|
||||||
const text = (otherText[qi] ?? '').trim();
|
|
||||||
if (text) record(qi, text);
|
|
||||||
}}
|
|
||||||
disabled={!(otherText[qi] ?? '').trim()}
|
disabled={!(otherText[qi] ?? '').trim()}
|
||||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{multi && !done && (
|
{multi && !done && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -11,7 +12,8 @@ type WebpageDialogProps = {
|
|||||||
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
|
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
|
||||||
const [urlInput, setUrlInput] = useState('');
|
const [urlInput, setUrlInput] = useState('');
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = (ev?: FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const url = urlInput.trim();
|
const url = urlInput.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
onSubmit(url);
|
onSubmit(url);
|
||||||
@@ -26,29 +28,23 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
|
|||||||
<DialogTitle>Attach Webpage</DialogTitle>
|
<DialogTitle>Attach Webpage</DialogTitle>
|
||||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex gap-2">
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={urlInput}
|
value={urlInput}
|
||||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
className="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
className="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
type="submit"
|
||||||
disabled={!urlInput.trim()}
|
disabled={!urlInput.trim()}
|
||||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Attach
|
Attach
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
|||||||
const goto = (idx: number) => setPath(idx < 0 ? '/' : `/${segments.slice(0, idx + 1).join('/')}`);
|
const goto = (idx: number) => setPath(idx < 0 ? '/' : `/${segments.slice(0, idx + 1).join('/')}`);
|
||||||
const enter = (name: string) => setPath(path === '/' ? `/${name}` : `${path}/${name}`);
|
const enter = (name: string) => setPath(path === '/' ? `/${name}` : `${path}/${name}`);
|
||||||
|
|
||||||
const createFolder = async () => {
|
const createFolder = async (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const n = newName.trim();
|
const n = newName.trim();
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
await api.createDir(path === '/' ? `/${n}` : `${path}/${n}`);
|
await api.createDir(path === '/' ? `/${n}` : `${path}/${n}`);
|
||||||
@@ -104,25 +105,24 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex flex-col gap-2 border-t border-border px-4 py-3">
|
<div className="flex flex-col gap-2 border-t border-border px-4 py-3">
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<div className="flex items-center gap-1">
|
<form onSubmit={createFolder} className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(ev) => setNewName(ev.target.value)}
|
onChange={(ev) => setNewName(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') createFolder();
|
|
||||||
if (ev.key === 'Escape') setCreating(false);
|
if (ev.key === 'Escape') setCreating(false);
|
||||||
}}
|
}}
|
||||||
placeholder="New folder name"
|
placeholder="New folder name"
|
||||||
className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={createFolder}
|
type="submit"
|
||||||
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
|
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -183,13 +183,18 @@ export const SessionRow = ({
|
|||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-2 px-4 py-3">
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void actions.commitRename();
|
||||||
|
}}
|
||||||
|
className="flex min-w-0 items-center gap-2 px-4 py-3"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={actions.editValue}
|
value={actions.editValue}
|
||||||
onChange={(ev) => actions.setEditValue(ev.target.value)}
|
onChange={(ev) => actions.setEditValue(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') void actions.commitRename();
|
|
||||||
if (ev.key === 'Escape') actions.setEditingId(null);
|
if (ev.key === 'Escape') actions.setEditingId(null);
|
||||||
}}
|
}}
|
||||||
onBlur={() => void actions.commitRename()}
|
onBlur={() => void actions.commitRename()}
|
||||||
@@ -197,8 +202,8 @@ export const SessionRow = ({
|
|||||||
className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none"
|
className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="submit"
|
||||||
onMouseDown={(ev) => ev.preventDefault()}
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
onClick={() => void actions.commitRename()}
|
|
||||||
className="cursor-pointer p-1 text-success hover:opacity-80"
|
className="cursor-pointer p-1 text-success hover:opacity-80"
|
||||||
title="Save"
|
title="Save"
|
||||||
aria-label="Save title"
|
aria-label="Save title"
|
||||||
@@ -206,6 +211,7 @@ export const SessionRow = ({
|
|||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onMouseDown={(ev) => ev.preventDefault()}
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
onClick={() => actions.setEditingId(null)}
|
onClick={() => actions.setEditingId(null)}
|
||||||
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
|
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
|
||||||
@@ -214,7 +220,7 @@ export const SessionRow = ({
|
|||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export const CommandHqCategories = () => {
|
|||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
const submitNew = async () => {
|
const submitNew = async (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
@@ -92,21 +93,23 @@ export const CommandHqCategories = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{adding && (
|
{adding && (
|
||||||
<input
|
// No submit button — a form with exactly one text field still submits on Enter.
|
||||||
autoFocus
|
<form onSubmit={submitNew}>
|
||||||
value={name}
|
<input
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
autoFocus
|
||||||
onBlur={submitNew}
|
value={name}
|
||||||
onKeyDown={(ev) => {
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
if (ev.key === 'Enter') submitNew();
|
onBlur={() => void submitNew()}
|
||||||
if (ev.key === 'Escape') {
|
onKeyDown={(ev) => {
|
||||||
setName('');
|
if (ev.key === 'Escape') {
|
||||||
setAdding(false);
|
setName('');
|
||||||
}
|
setAdding(false);
|
||||||
}}
|
}
|
||||||
placeholder="Category name"
|
}}
|
||||||
className="mx-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm outline-none focus:border-primary"
|
placeholder="Category name"
|
||||||
/>
|
className="mx-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
{!adding && categories.length === 0 && (
|
{!adding && categories.length === 0 && (
|
||||||
<div className="px-3 py-1 text-xs text-muted-foreground">No categories yet</div>
|
<div className="px-3 py-1 text-xs text-muted-foreground">No categories yet</div>
|
||||||
|
|||||||
+23
-14
@@ -503,22 +503,31 @@ const InlineRenameInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
// A real <form> so Enter commits the rename — this was a bare input relying on a manual keydown
|
||||||
ref={inputRef}
|
// check. `contents` keeps the extra element invisible to the surrounding flex/grid layout.
|
||||||
value={value}
|
<form
|
||||||
onChange={(ev) => setValue(ev.target.value)}
|
onSubmit={(ev) => {
|
||||||
onBlur={() => {
|
ev.preventDefault();
|
||||||
// Ignore blur before the input is ready (context menu closing steals focus)
|
|
||||||
if (!ready.current) return;
|
|
||||||
commit();
|
commit();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(ev) => {
|
className="contents"
|
||||||
if (ev.key === 'Enter') commit();
|
>
|
||||||
if (ev.key === 'Escape') onCancel();
|
<input
|
||||||
}}
|
ref={inputRef}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
value={value}
|
||||||
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none max-w-full text-left"
|
onChange={(ev) => setValue(ev.target.value)}
|
||||||
/>
|
onBlur={() => {
|
||||||
|
// Ignore blur before the input is ready (context menu closing steals focus)
|
||||||
|
if (!ready.current) return;
|
||||||
|
commit();
|
||||||
|
}}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Escape') onCancel();
|
||||||
|
}}
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none max-w-full text-left"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+35
-35
@@ -104,7 +104,8 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn
|
|||||||
const problem = kind === 'clone' ? urlProblem(name) : nameProblem(name, existingNames);
|
const problem = kind === 'clone' ? urlProblem(name) : nameProblem(name, existingNames);
|
||||||
const canSubmit = name.trim().length > 0 && problem === null;
|
const canSubmit = name.trim().length > 0 && problem === null;
|
||||||
|
|
||||||
const commit = () => {
|
const commit = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(name.trim());
|
onSubmit(name.trim());
|
||||||
onClose();
|
onClose();
|
||||||
@@ -113,42 +114,41 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn
|
|||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
{/* A real <form> so Enter submits — this was a div with a manual keydown check for Enter, the
|
||||||
<DialogTitle className="flex items-center gap-2">
|
button was the only way in. `grid gap-4` replaces DialogContent's own gap now that the form
|
||||||
<Icon className="h-4 w-4" />
|
is the single child spanning header/fields/footer. */}
|
||||||
{title}
|
<form onSubmit={commit} className="grid gap-4">
|
||||||
</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogTitle className="flex items-center gap-2">
|
||||||
</DialogHeader>
|
<Icon className="h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-entry-name">{label}</Label>
|
<Label htmlFor="new-entry-name">{label}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new-entry-name"
|
id="new-entry-name"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={name}
|
value={name}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
/>
|
||||||
if (ev.key === 'Enter') {
|
{/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you
|
||||||
ev.preventDefault();
|
type past a colliding name. */}
|
||||||
commit();
|
<p className="min-h-[1.25rem] text-xs text-destructive">{problem ?? ''}</p>
|
||||||
}
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you
|
|
||||||
type past a colliding name. */}
|
|
||||||
<p className="min-h-[1.25rem] text-xs text-destructive">{problem ?? ''}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={commit} disabled={!canSubmit}>
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
{submit}
|
{submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
+37
-23
@@ -157,7 +157,8 @@ const AgenticTaskRunner = ({
|
|||||||
wasGenerating.current = chat.isGenerating;
|
wasGenerating.current = chat.isGenerating;
|
||||||
}, [chat.isGenerating]);
|
}, [chat.isGenerating]);
|
||||||
|
|
||||||
const handleRun = () => {
|
const handleRun = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
// Build prompt from task body + input values, or fall back to generic prompt
|
// Build prompt from task body + input values, or fall back to generic prompt
|
||||||
let prompt = defaultInput;
|
let prompt = defaultInput;
|
||||||
if (taskBody && inputDefs) {
|
if (taskBody && inputDefs) {
|
||||||
@@ -182,24 +183,29 @@ const AgenticTaskRunner = ({
|
|||||||
if (phase === 'ready') {
|
if (phase === 'ready') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasConfigurableInputs && (
|
{/* A real <form> so Enter runs the task — this was a div with no submit path. `contents` keeps
|
||||||
<TaskInputForm
|
the wrapper invisible to the flex layout; the ModelSelector stays a sibling, outside the
|
||||||
inputDefs={inputDefs}
|
form, since it renders its own untyped buttons that must not become submit buttons. */}
|
||||||
values={formValues}
|
<form onSubmit={handleRun} className="contents">
|
||||||
onChange={handleInputChange}
|
{hasConfigurableInputs && (
|
||||||
autoFilledKeys={new Set()}
|
<TaskInputForm
|
||||||
/>
|
inputDefs={inputDefs}
|
||||||
)}
|
values={formValues}
|
||||||
<div className="flex-1 flex items-center justify-center">
|
onChange={handleInputChange}
|
||||||
<button
|
autoFilledKeys={new Set()}
|
||||||
onClick={handleRun}
|
/>
|
||||||
disabled={!chat.isConnected || !inputDefs}
|
)}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
<div className="flex-1 flex items-center justify-center">
|
||||||
>
|
<button
|
||||||
<Play className="h-4 w-4" />
|
type="submit"
|
||||||
Run
|
disabled={!chat.isConnected || !inputDefs}
|
||||||
</button>
|
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
</div>
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<div className="shrink-0 px-4 py-3 border-t border-duck-dark/10">
|
<div className="shrink-0 px-4 py-3 border-t border-duck-dark/10">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
messages={[]}
|
messages={[]}
|
||||||
@@ -451,13 +457,17 @@ export const TaskInputForm = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
||||||
|
{/* type="button": this form's toggles must never submit it, since they now live inside a
|
||||||
|
real <form> (Agentic/Pipeline ready phases). */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onChange(key, 'false')}
|
onClick={() => onChange(key, 'false')}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${!isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${!isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
No
|
No
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onChange(key, 'true')}
|
onClick={() => onChange(key, 'true')}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
@@ -493,6 +503,7 @@ export const TaskInputForm = ({
|
|||||||
{def.options.map((opt) => (
|
{def.options.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt}
|
key={opt}
|
||||||
|
type="button"
|
||||||
onClick={() => onChange(key, opt)}
|
onClick={() => onChange(key, opt)}
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${selected === opt ? 'bg-duck-teal text-white' : 'bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/60 dark:text-foreground/60 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/10 dark:hover:bg-foreground/10'}`}
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${selected === opt ? 'bg-duck-teal text-white' : 'bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/60 dark:text-foreground/60 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/10 dark:hover:bg-foreground/10'}`}
|
||||||
>
|
>
|
||||||
@@ -1404,7 +1415,8 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
|||||||
prevPhaseRef.current = pipeline.phase;
|
prevPhaseRef.current = pipeline.phase;
|
||||||
}, [pipeline.phase]);
|
}, [pipeline.phase]);
|
||||||
|
|
||||||
const handleRun = () => {
|
const handleRun = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const allInputs = { ...formValues };
|
const allInputs = { ...formValues };
|
||||||
if (hasConcurrentSteps && parseInt(concurrency, 10) > 1) {
|
if (hasConcurrentSteps && parseInt(concurrency, 10) > 1) {
|
||||||
allInputs._concurrency = concurrency;
|
allInputs._concurrency = concurrency;
|
||||||
@@ -1416,7 +1428,8 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
|||||||
|
|
||||||
if (pipeline.phase === 'ready') {
|
if (pipeline.phase === 'ready') {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
// A real <form> so Enter runs the pipeline — this was a div with no submit path.
|
||||||
|
<form onSubmit={handleRun} className="flex-1 flex flex-col">
|
||||||
{hasConfigurableInputs && (
|
{hasConfigurableInputs && (
|
||||||
<TaskInputForm
|
<TaskInputForm
|
||||||
inputDefs={inputDefs}
|
inputDefs={inputDefs}
|
||||||
@@ -1462,6 +1475,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
|||||||
{pipelineSteps.map((step, i) => (
|
{pipelineSteps.map((step, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
type="button"
|
||||||
onClick={() => setStartAt(i)}
|
onClick={() => setStartAt(i)}
|
||||||
className={`text-left text-xs px-2.5 py-1.5 rounded-md transition-colors cursor-pointer flex items-center gap-2 ${
|
className={`text-left text-xs px-2.5 py-1.5 rounded-md transition-colors cursor-pointer flex items-center gap-2 ${
|
||||||
i < startAt
|
i < startAt
|
||||||
@@ -1482,7 +1496,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
type="submit"
|
||||||
disabled={!pipeline.isConnected || !inputDefs}
|
disabled={!pipeline.isConnected || !inputDefs}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1490,7 +1504,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
|||||||
{startAt > 0 ? `Run from step ${startAt + 1}` : 'Run Pipeline'}
|
{startAt > 0 ? `Run from step ${startAt + 1}` : 'Run Pipeline'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
if (!editing) setDraft(display);
|
if (!editing) setDraft(display);
|
||||||
}, [display, editing]);
|
}, [display, editing]);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const next = normalizeUrl(draft);
|
const next = normalizeUrl(draft);
|
||||||
if (next) setUrl(next);
|
if (next) setUrl(next);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -34,25 +35,29 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<>
|
<form onSubmit={commit} className="contents">
|
||||||
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
|
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
onKeyDown={(ev) => {
|
||||||
if (ev.key === 'Enter') commit();
|
|
||||||
if (ev.key === 'Escape') setEditing(false);
|
if (ev.key === 'Escape') setEditing(false);
|
||||||
}}
|
}}
|
||||||
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
||||||
/>
|
/>
|
||||||
<button onClick={commit} title="Save" className="p-0.5 cursor-pointer shrink-0">
|
<button type="submit" title="Save" className="p-0.5 cursor-pointer shrink-0">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setEditing(false)} title="Cancel" className="p-0.5 cursor-pointer shrink-0">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
title="Cancel"
|
||||||
|
className="p-0.5 cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export const DailyGoals = () => {
|
|||||||
const [newGoal, setNewGoal] = useState('');
|
const [newGoal, setNewGoal] = useState('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const addGoal = () => {
|
const addGoal = (ev?: React.FormEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
const text = newGoal.trim();
|
const text = newGoal.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
nextGoalId++;
|
nextGoalId++;
|
||||||
@@ -131,13 +132,22 @@ export const DailyGoals = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
|
||||||
goal.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'
|
goal.done
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:border-primary'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleGoal(goal.id)}
|
onClick={() => toggleGoal(goal.id)}
|
||||||
>
|
>
|
||||||
{goal.done && (
|
{goal.done && (
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
<path d="M2 5l2.5 2.5L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path
|
||||||
|
d="M2 5l2.5 2.5L8 3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -162,29 +172,22 @@ export const DailyGoals = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<form onSubmit={addGoal} className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Add a goal..."
|
placeholder="Add a goal..."
|
||||||
value={newGoal}
|
value={newGoal}
|
||||||
onChange={(ev) => setNewGoal(ev.target.value)}
|
onChange={(ev) => setNewGoal(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
addGoal();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||||
onClick={addGoal}
|
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,14 +79,14 @@ function usePomodoroState() {
|
|||||||
type Phase = 'focus' | 'break' | 'long-break';
|
type Phase = 'focus' | 'break' | 'long-break';
|
||||||
|
|
||||||
const phaseLabel: Record<Phase, string> = {
|
const phaseLabel: Record<Phase, string> = {
|
||||||
'focus': 'Focus',
|
focus: 'Focus',
|
||||||
'break': 'Break',
|
break: 'Break',
|
||||||
'long-break': 'Long Break',
|
'long-break': 'Long Break',
|
||||||
};
|
};
|
||||||
|
|
||||||
const phaseColor: Record<Phase, string> = {
|
const phaseColor: Record<Phase, string> = {
|
||||||
'focus': 'text-destructive',
|
focus: 'text-destructive',
|
||||||
'break': 'text-success',
|
break: 'text-success',
|
||||||
'long-break': 'text-accent',
|
'long-break': 'text-accent',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,7 +116,8 @@ const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const source = activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
|
const source =
|
||||||
|
activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
|
||||||
if (!search) return source;
|
if (!search) return source;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return source.filter((s) => s.name.toLowerCase().includes(q));
|
return source.filter((s) => s.name.toLowerCase().includes(q));
|
||||||
@@ -165,7 +166,10 @@ const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer"
|
className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
onClick={() => { stopPreview(); setOpen(false); }}
|
onClick={() => {
|
||||||
|
stopPreview();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
@@ -175,6 +179,11 @@ const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
|||||||
placeholder="Search sounds..."
|
placeholder="Search sounds..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
|
// This is a filter, not a submit — but it now lives inside SettingsPanel's <form>, so without
|
||||||
|
// this it would submit the settings on Enter instead of just narrowing the list.
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') ev.preventDefault();
|
||||||
|
}}
|
||||||
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -199,23 +208,31 @@ const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
|||||||
className={`flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs cursor-pointer transition-colors ${
|
className={`flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs cursor-pointer transition-colors ${
|
||||||
value === null ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
value === null ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => { stopPreview(); onChange(null); }}
|
onClick={() => {
|
||||||
|
stopPreview();
|
||||||
|
onChange(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
None (silent)
|
None (silent)
|
||||||
</button>
|
</button>
|
||||||
{filtered.map((s) => (
|
{filtered.map((s) => (
|
||||||
<div key={s.name} className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${value === s.name ? 'bg-primary/10 text-primary' : ''}`}>
|
<div
|
||||||
|
key={s.name}
|
||||||
|
className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${value === s.name ? 'bg-primary/10 text-primary' : ''}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => previewName === s.name ? stopPreview() : preview(s)}
|
onClick={() => (previewName === s.name ? stopPreview() : preview(s))}
|
||||||
>
|
>
|
||||||
{previewName === s.name ? <Square size={10} /> : <Play size={10} />}
|
{previewName === s.name ? <Square size={10} /> : <Play size={10} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1 text-left cursor-pointer hover:text-foreground transition-colors truncate"
|
className="flex-1 text-left cursor-pointer hover:text-foreground transition-colors truncate"
|
||||||
onClick={() => { onChange(s.name); }}
|
onClick={() => {
|
||||||
|
onChange(s.name);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formatSoundName(s.name)}
|
{formatSoundName(s.name)}
|
||||||
</button>
|
</button>
|
||||||
@@ -229,15 +246,23 @@ const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
|||||||
const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSave: (s: PomodoroSettings) => void }) => {
|
const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSave: (s: PomodoroSettings) => void }) => {
|
||||||
const [draft, setDraft] = useState(settings);
|
const [draft, setDraft] = useState(settings);
|
||||||
|
|
||||||
const numericFields: { key: 'focusMinutes' | 'breakMinutes' | 'longBreakMinutes' | 'sessionsBeforeLong'; label: string }[] = [
|
const numericFields: {
|
||||||
|
key: 'focusMinutes' | 'breakMinutes' | 'longBreakMinutes' | 'sessionsBeforeLong';
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
{ key: 'focusMinutes', label: 'Focus (min)' },
|
{ key: 'focusMinutes', label: 'Focus (min)' },
|
||||||
{ key: 'breakMinutes', label: 'Break (min)' },
|
{ key: 'breakMinutes', label: 'Break (min)' },
|
||||||
{ key: 'longBreakMinutes', label: 'Long break (min)' },
|
{ key: 'longBreakMinutes', label: 'Long break (min)' },
|
||||||
{ key: 'sessionsBeforeLong', label: 'Sessions before long' },
|
{ key: 'sessionsBeforeLong', label: 'Sessions before long' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const save = (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
onSave(draft);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
<form onSubmit={save} className="flex flex-col gap-2 px-4 pb-4">
|
||||||
{numericFields.map(({ key, label }) => (
|
{numericFields.map(({ key, label }) => (
|
||||||
<div key={key} className="flex items-center justify-between gap-2">
|
<div key={key} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
@@ -262,14 +287,13 @@ const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSav
|
|||||||
onChange={(name) => setDraft((d) => ({ ...d, breakEndSound: name }))}
|
onChange={(name) => setDraft((d) => ({ ...d, breakEndSound: name }))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="mt-1 flex items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
className="mt-1 flex items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
onClick={() => onSave(draft)}
|
|
||||||
>
|
>
|
||||||
<Check size={12} />
|
<Check size={12} />
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,7 +387,15 @@ export const Pomodoro = () => {
|
|||||||
<div className="flex flex-col items-center gap-3 px-4 pb-4">
|
<div className="flex flex-col items-center gap-3 px-4 pb-4">
|
||||||
<div className="relative flex items-center justify-center">
|
<div className="relative flex items-center justify-center">
|
||||||
<svg width="100" height="100" className="-rotate-90">
|
<svg width="100" height="100" className="-rotate-90">
|
||||||
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
className="text-muted/30"
|
||||||
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx="50"
|
cx="50"
|
||||||
cy="50"
|
cy="50"
|
||||||
@@ -407,7 +439,9 @@ export const Pomodoro = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>{completedToday} session{completedToday !== 1 ? 's' : ''} today</span>
|
<span>
|
||||||
|
{completedToday} session{completedToday !== 1 ? 's' : ''} today
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
Reference in New Issue
Block a user