use settings input component

This commit is contained in:
DaKheera47 2026-01-22 12:24:14 +00:00
parent 0424a29008
commit a81b1f0e58
12 changed files with 277 additions and 372 deletions

View File

@ -205,10 +205,7 @@ export async function updateSettings(update: {
basicAuthPassword?: string | null
ukvisajobsEmail?: string | null
ukvisajobsPassword?: string | null
ukvisajobsHeadless?: boolean | null
webhookSecret?: string | null
notionApiKey?: string | null
notionDatabaseId?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',

View File

@ -102,10 +102,8 @@ const baseSettings: AppSettings = {
basicAuthPasswordHint: null,
ukvisajobsEmail: "",
ukvisajobsPasswordHint: null,
ukvisajobsHeadless: true,
webhookSecretHint: null,
notionApiKeyHint: null,
notionDatabaseId: "",
basicAuthActive: false,
}
const renderPage = () => {

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect } from "react"
import { useFormContext, Controller } from "react-hook-form"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { EnvSettingsValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type EnvironmentSettingsSectionProps = {
values: EnvSettingsValues
@ -21,8 +21,8 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
isLoading,
isSaving,
}) => {
const { register, control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { readable, private: privateValues, basicAuthActive } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { private: privateValues, basicAuthActive } = values
const [isBasicAuthEnabled, setIsBasicAuthEnabled] = useState(basicAuthActive)
@ -41,33 +41,25 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">OpenRouter API key</div>
<Input
{...register("openrouterApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
/>
{errors.openrouterApiKey && <p className="text-xs text-destructive">{errors.openrouterApiKey.message}</p>}
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{formatSecretHint(privateValues.openrouterApiKeyHint)}</span>
</div>
</div>
<SettingsInput
label="OpenRouter API key"
inputProps={register("openrouterApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
error={errors.openrouterApiKey?.message as string | undefined}
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Webhook secret</div>
<Input
{...register("webhookSecret")}
type="password"
placeholder="Enter new secret"
disabled={isLoading || isSaving}
/>
{errors.webhookSecret && <p className="text-xs text-destructive">{errors.webhookSecret.message}</p>}
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{formatSecretHint(privateValues.webhookSecretHint)}</span>
</div>
</div>
<SettingsInput
label="Webhook secret"
inputProps={register("webhookSecret")}
type="password"
placeholder="Enter new secret"
disabled={isLoading || isSaving}
error={errors.webhookSecret?.message as string | undefined}
current={formatSecretHint(privateValues.webhookSecretHint)}
/>
</div>
</div>
@ -80,56 +72,44 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
<div className="space-y-4">
<div className="text-sm font-semibold">RxResume</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm">Email</div>
<Input
{...register("rxresumeEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
/>
{errors.rxresumeEmail && <p className="text-xs text-destructive">{errors.rxresumeEmail.message}</p>}
</div>
<div className="space-y-2">
<div className="text-sm">Password</div>
<Input
{...register("rxresumePassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
/>
{errors.rxresumePassword && <p className="text-xs text-destructive">{errors.rxresumePassword.message}</p>}
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{formatSecretHint(privateValues.rxresumePasswordHint)}</span>
</div>
</div>
<SettingsInput
label="Email"
inputProps={register("rxresumeEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.rxresumeEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("rxresumePassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.rxresumePassword?.message as string | undefined}
current={formatSecretHint(privateValues.rxresumePasswordHint)}
/>
</div>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">UKVisaJobs</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm">Email</div>
<Input
{...register("ukvisajobsEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
/>
{errors.ukvisajobsEmail && <p className="text-xs text-destructive">{errors.ukvisajobsEmail.message}</p>}
</div>
<div className="space-y-2">
<div className="text-sm">Password</div>
<Input
{...register("ukvisajobsPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
/>
{errors.ukvisajobsPassword && <p className="text-xs text-destructive">{errors.ukvisajobsPassword.message}</p>}
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{formatSecretHint(privateValues.ukvisajobsPasswordHint)}</span>
</div>
</div>
<SettingsInput
label="Email"
inputProps={register("ukvisajobsEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.ukvisajobsEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("ukvisajobsPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.ukvisajobsPassword?.message as string | undefined}
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
/>
</div>
</div>
</div>
@ -161,29 +141,23 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
{isBasicAuthEnabled && (
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<div className="text-sm">Username</div>
<Input
{...register("basicAuthUser")}
placeholder="username"
disabled={isLoading || isSaving}
/>
{errors.basicAuthUser && <p className="text-xs text-destructive">{errors.basicAuthUser.message}</p>}
</div>
<SettingsInput
label="Username"
inputProps={register("basicAuthUser")}
placeholder="username"
disabled={isLoading || isSaving}
error={errors.basicAuthUser?.message as string | undefined}
/>
<div className="space-y-2">
<div className="text-sm">Password</div>
<Input
{...register("basicAuthPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
/>
{errors.basicAuthPassword && <p className="text-xs text-destructive">{errors.basicAuthPassword.message}</p>}
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{formatSecretHint(privateValues.basicAuthPasswordHint)}</span>
</div>
</div>
<SettingsInput
label="Password"
inputProps={register("basicAuthPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.basicAuthPassword?.message as string | undefined}
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
/>
</div>
)}
</div>
@ -192,4 +166,3 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
</AccordionItem>
)
}

View File

@ -2,10 +2,9 @@ import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type GradcrackerSectionProps = {
values: NumericSettingValues
@ -28,48 +27,35 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs per search term"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.gradcrackerMaxJobsPerTerm && <p className="text-xs text-destructive">{errors.gradcrackerMaxJobsPerTerm.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
</div>
</div>
},
}}
disabled={isLoading || isSaving}
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined}
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
current={String(effectiveGradcrackerMaxJobsPerTerm)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -2,10 +2,9 @@ import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type JobCompleteWebhookSectionProps = {
values: WebhookValues
@ -28,31 +27,15 @@ export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
{...register("jobCompleteWebhookUrl")}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.jobCompleteWebhookUrl && <p className="text-xs text-destructive">{errors.jobCompleteWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
<SettingsInput
label="Job completion webhook URL"
inputProps={register("jobCompleteWebhookUrl")}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${defaultJobCompleteWebhookUrl || "—"}.`}
current={effectiveJobCompleteWebhookUrl || "—"}
/>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -3,10 +3,10 @@ import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { JobspyValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type JobspySectionProps = {
values: JobspyValues
@ -99,107 +99,85 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
{...register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyLocation && <p className="text-xs text-destructive">{errors.jobspyLocation.message}</p>}
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {location.effective || "—"}</span>
<span>Default: {location.default || "—"}</span>
</div>
</div>
<SettingsInput
label="Location"
inputProps={register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyLocation?.message as string | undefined}
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? resultsWanted.default}
onChange={(event) => {
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<SettingsInput
label="Results Wanted"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? resultsWanted.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyResultsWanted && <p className="text-xs text-destructive">{errors.jobspyResultsWanted.message}</p>}
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 1000.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {resultsWanted.effective}</span>
<span>Default: {resultsWanted.default}</span>
</div>
</div>
},
}}
disabled={isLoading || isSaving}
error={errors.jobspyResultsWanted?.message as string | undefined}
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
/>
)}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={720}
value={field.value ?? hoursOld.default}
onChange={(event) => {
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<SettingsInput
label="Hours Old"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 720,
value: field.value ?? hoursOld.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(720, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyHoursOld && <p className="text-xs text-destructive">{errors.jobspyHoursOld.message}</p>}
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {hoursOld.effective}h</span>
<span>Default: {hoursOld.default}h</span>
</div>
</div>
},
}}
disabled={isLoading || isSaving}
error={errors.jobspyHoursOld?.message as string | undefined}
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
/>
)}
/>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
{...register("jobspyCountryIndeed")}
placeholder={countryIndeed.default || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyCountryIndeed && <p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>}
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {countryIndeed.effective || "—"}</span>
<span>Default: {countryIndeed.default || "—"}</span>
</div>
</div>
<SettingsInput
label="Indeed Country"
inputProps={register("jobspyCountryIndeed")}
placeholder={countryIndeed.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyCountryIndeed?.message as string | undefined}
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
/>
</div>
<Separator />

View File

@ -2,10 +2,10 @@ import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { ModelValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type ModelSettingsSectionProps = {
values: ModelValues
@ -28,18 +28,15 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
{...register("model")}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
{errors.model && <p className="text-xs text-destructive">{errors.model.message}</p>}
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<SettingsInput
label="Override model"
inputProps={register("model")}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
error={errors.model?.message as string | undefined}
helper="Leave blank to use the default from server env (`MODEL`)."
current={effective || "—"}
/>
<Separator />
@ -47,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="text-sm font-medium">Task-Specific Overrides</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
{...register("modelScorer")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelScorer && <p className="text-xs text-destructive">{errors.modelScorer.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{scorer || effective}</span>
</div>
</div>
<SettingsInput
label="Scoring Model"
inputProps={register("modelScorer")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelScorer?.message as string | undefined}
current={scorer || effective || "—"}
/>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
{...register("modelTailoring")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelTailoring && <p className="text-xs text-destructive">{errors.modelTailoring.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{tailoring || effective}</span>
</div>
</div>
<SettingsInput
label="Tailoring Model"
inputProps={register("modelTailoring")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelTailoring?.message as string | undefined}
current={tailoring || effective || "—"}
/>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
{...register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelProjectSelection && <p className="text-xs text-destructive">{errors.modelProjectSelection.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{projectSelection || effective}</span>
</div>
</div>
<SettingsInput
label="Project Selection Model"
inputProps={register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelProjectSelection?.message as string | undefined}
current={projectSelection || effective || "—"}
/>
</div>
</div>

View File

@ -2,10 +2,9 @@ import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { WebhookValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type PipelineWebhookSectionProps = {
values: WebhookValues
@ -28,31 +27,15 @@ export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
{...register("pipelineWebhookUrl")}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.pipelineWebhookUrl && <p className="text-xs text-destructive">{errors.pipelineWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
<SettingsInput
label="Pipeline status webhook URL"
inputProps={register("pipelineWebhookUrl")}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
error={errors.pipelineWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST on pipeline completion/failure. Default: ${defaultPipelineWebhookUrl || "—"}.`}
current={effectivePipelineWebhookUrl || "—"}
/>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -0,0 +1,39 @@
import React from "react"
import { Input } from "@/components/ui/input"
type SettingsInputProps = {
label: string
inputProps: React.InputHTMLAttributes<HTMLInputElement>
placeholder?: string
type?: React.HTMLInputTypeAttribute
disabled?: boolean
error?: string
helper?: string
current?: string | null
}
export const SettingsInput: React.FC<SettingsInputProps> = ({
label,
inputProps,
placeholder,
type = "text",
disabled,
error,
helper,
current,
}) => {
return (
<div className="space-y-2">
<div className="text-sm font-medium">{label}</div>
<Input {...inputProps} type={type} placeholder={placeholder} disabled={disabled} />
{error && <p className="text-xs text-destructive">{error}</p>}
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
{current !== undefined && (
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{current}</span>
</div>
)}
</div>
)
}

View File

@ -2,10 +2,9 @@ import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
import type { NumericSettingValues } from "@client/pages/settings/types"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
type UkvisajobsSectionProps = {
values: NumericSettingValues
@ -28,48 +27,35 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<SettingsInput
label="Max jobs to fetch"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.ukvisajobsMaxJobs && <p className="text-xs text-destructive">{errors.ukvisajobsMaxJobs.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
</div>
</div>
},
}}
disabled={isLoading || isSaving}
error={errors.ukvisajobsMaxJobs?.message as string | undefined}
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`}
current={String(effectiveUkvisajobsMaxJobs)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -28,8 +28,6 @@ export type EnvSettingsValues = {
rxresumeEmail: string
ukvisajobsEmail: string
basicAuthUser: string
notionDatabaseId: string
ukvisajobsHeadless: boolean
}
private: {
openrouterApiKeyHint: string | null

View File

@ -5,7 +5,6 @@ import {
applyEnvValue,
getEnvSettingsData,
normalizeEnvInput,
serializeEnvBoolean,
} from '@server/services/envSettings.js';
import {
extractProjectsFromProfile,