use settings input component
This commit is contained in:
parent
0424a29008
commit
a81b1f0e58
@ -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',
|
||||
|
||||
@ -102,10 +102,8 @@ const baseSettings: AppSettings = {
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPasswordHint: null,
|
||||
ukvisajobsHeadless: true,
|
||||
webhookSecretHint: null,
|
||||
notionApiKeyHint: null,
|
||||
notionDatabaseId: "",
|
||||
basicAuthActive: false,
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -28,8 +28,6 @@ export type EnvSettingsValues = {
|
||||
rxresumeEmail: string
|
||||
ukvisajobsEmail: string
|
||||
basicAuthUser: string
|
||||
notionDatabaseId: string
|
||||
ukvisajobsHeadless: boolean
|
||||
}
|
||||
private: {
|
||||
openrouterApiKeyHint: string | null
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
applyEnvValue,
|
||||
getEnvSettingsData,
|
||||
normalizeEnvInput,
|
||||
serializeEnvBoolean,
|
||||
} from '@server/services/envSettings.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user