settings page split up into components

This commit is contained in:
DaKheera47 2026-01-20 06:14:05 +00:00
parent 65551b147f
commit 4325737d00
12 changed files with 1171 additions and 766 deletions

View File

@ -3,70 +3,24 @@
*/
import React, { useEffect, useMemo, useState } from "react"
import { AlertTriangle, Settings, Trash2 } from "lucide-react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { PageHeader } from "../components/layout"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
import * as api from "../api"
/** All available job statuses for clearing */
const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
/** Status descriptions for UI */
const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed',
processing: 'Currently generating resume',
ready: 'PDF generated, waiting for user to apply',
applied: 'User marked as applied',
skipped: 'User skipped this job',
expired: 'Deadline passed',
}
function arraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
return (
a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
)
}
function clampInt(value: number, min: number, max: number) {
const int = Math.floor(value)
if (Number.isNaN(int)) return min
return Math.min(max, Math.max(min, int))
}
import { arraysEqual, resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
@ -332,7 +286,7 @@ export const SettingsPage: React.FC = () => {
setIsSaving(true)
let totalDeleted = 0
const results: string[] = []
for (const status of statusesToClear) {
const result = await api.deleteJobsByStatus(status)
totalDeleted += result.count
@ -340,14 +294,14 @@ export const SettingsPage: React.FC = () => {
results.push(`${result.count} ${status}`)
}
}
if (totalDeleted > 0) {
toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`
toast.success("Jobs cleared", {
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
})
} else {
toast.info("No jobs found", {
description: `No jobs with selected statuses found`
toast.info("No jobs found", {
description: `No jobs with selected statuses found`,
})
}
} catch (error) {
@ -359,8 +313,8 @@ export const SettingsPage: React.FC = () => {
}
const toggleStatusToClear = (status: JobStatus) => {
setStatusesToClear(prev =>
prev.includes(status)
setStatusesToClear(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
)
@ -422,707 +376,119 @@ export const SettingsPage: React.FC = () => {
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4">
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Model</span>
</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
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<ModelSettingsSection
modelDraft={modelDraft}
setModelDraft={setModelDraft}
modelScorerDraft={modelScorerDraft}
setModelScorerDraft={setModelScorerDraft}
modelTailoringDraft={modelTailoringDraft}
setModelTailoringDraft={setModelTailoringDraft}
modelProjectSelectionDraft={modelProjectSelectionDraft}
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
effectiveModel={effectiveModel}
effectiveModelScorer={effectiveModelScorer}
effectiveModelTailoring={effectiveModelTailoring}
effectiveModelProjectSelection={effectiveModelProjectSelection}
defaultModel={defaultModel}
isLoading={isLoading}
isSaving={isSaving}
/>
<PipelineWebhookSection
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
searchTermsDraft={searchTermsDraft}
setSearchTermsDraft={setSearchTermsDraft}
defaultSearchTerms={defaultSearchTerms}
effectiveSearchTerms={effectiveSearchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={defaultJobspySites}
effectiveJobspySites={effectiveJobspySites}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation={defaultJobspyLocation}
effectiveJobspyLocation={effectiveJobspyLocation}
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={defaultJobspyHoursOld}
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
resumeProjectsDraft={resumeProjectsDraft}
setResumeProjectsDraft={setResumeProjectsDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}
handleClearByStatuses={handleClearByStatuses}
handleClearDatabase={handleClearDatabase}
isLoading={isLoading}
isSaving={isSaving}
/>
</Accordion>
<Separator />
<div className="space-y-4">
<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
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</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
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</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
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
</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>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Gradcracker Extractor</span>
</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>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search Terms</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</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">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">JobSpy Scraper</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
<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: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
<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: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="linkedin-desc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Fetch LinkedIn Description
</label>
<p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume Projects</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
/>
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
<div className="text-xs text-muted-foreground">
Select which job statuses you want to clear.
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status)
return (
<button
key={status}
type="button"
onClick={() => toggleStatusToClear(status)}
disabled={isLoading || isSaving}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
}`}
>
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
isSelected ? 'border-destructive' : 'border-muted-foreground'
}`}>
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
</div>
<div className="grid gap-0.5">
<span className="text-sm font-medium capitalize">{status}</span>
<span className="text-xs text-muted-foreground">
{STATUS_DESCRIPTIONS[status]}
</span>
</div>
</button>
)
})}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Separator />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
<div className="text-xs text-muted-foreground">
Delete all jobs and pipeline runs from the database.
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
<Trash2 className="mr-2 h-4 w-4" />
Clear Database
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
</main>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
</main>
</>
)
}

View File

@ -0,0 +1,150 @@
import React from "react"
import { AlertTriangle, Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]
toggleStatusToClear: (status: JobStatus) => void
handleClearByStatuses: () => void
handleClearDatabase: () => void
isLoading: boolean
isSaving: boolean
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
statusesToClear,
toggleStatusToClear,
handleClearByStatuses,
handleClearDatabase,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4 pt-2">
<div className="p-3 rounded-md space-y-4">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
<div className="text-xs text-muted-foreground">
Select which job statuses you want to clear.
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{ALL_JOB_STATUSES.map((status) => {
const isSelected = statusesToClear.includes(status)
return (
<button
key={status}
type="button"
onClick={() => toggleStatusToClear(status)}
disabled={isLoading || isSaving}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
}`}
>
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
isSelected ? 'border-destructive' : 'border-muted-foreground'
}`}>
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
</div>
<div className="grid gap-0.5">
<span className="text-sm font-medium capitalize">{status}</span>
<span className="text-xs text-muted-foreground">
{STATUS_DESCRIPTIONS[status]}
</span>
</div>
</button>
)
})}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isLoading || isSaving || statusesToClear.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Selected ({statusesToClear.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Separator />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
<div className="space-y-0.5">
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
<div className="text-xs text-muted-foreground">
Delete all jobs and pipeline runs from the database.
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
<Trash2 className="mr-2 h-4 w-4" />
Clear Database
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Clear database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,70 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type GradcrackerSectionProps = {
gradcrackerMaxJobsPerTermDraft: number | null
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
defaultGradcrackerMaxJobsPerTerm: number
effectiveGradcrackerMaxJobsPerTerm: number
isLoading: boolean
isSaving: boolean
}
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
gradcrackerMaxJobsPerTermDraft,
setGradcrackerMaxJobsPerTermDraft,
defaultGradcrackerMaxJobsPerTerm,
effectiveGradcrackerMaxJobsPerTerm,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Gradcracker Extractor</span>
</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>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,60 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type JobCompleteWebhookSectionProps = {
jobCompleteWebhookUrlDraft: string
setJobCompleteWebhookUrlDraft: (value: string) => void
defaultJobCompleteWebhookUrl: string
effectiveJobCompleteWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
jobCompleteWebhookUrlDraft,
setJobCompleteWebhookUrlDraft,
defaultJobCompleteWebhookUrl,
effectiveJobCompleteWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</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
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,240 @@
import React from "react"
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"
type JobspySectionProps = {
jobspySitesDraft: string[] | null
setJobspySitesDraft: (value: string[] | null) => void
defaultJobspySites: string[]
effectiveJobspySites: string[]
jobspyLocationDraft: string | null
setJobspyLocationDraft: (value: string | null) => void
defaultJobspyLocation: string
effectiveJobspyLocation: string
jobspyResultsWantedDraft: number | null
setJobspyResultsWantedDraft: (value: number | null) => void
defaultJobspyResultsWanted: number
effectiveJobspyResultsWanted: number
jobspyHoursOldDraft: number | null
setJobspyHoursOldDraft: (value: number | null) => void
defaultJobspyHoursOld: number
effectiveJobspyHoursOld: number
jobspyCountryIndeedDraft: string | null
setJobspyCountryIndeedDraft: (value: string | null) => void
defaultJobspyCountryIndeed: string
effectiveJobspyCountryIndeed: string
jobspyLinkedinFetchDescriptionDraft: boolean | null
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
defaultJobspyLinkedinFetchDescription: boolean
effectiveJobspyLinkedinFetchDescription: boolean
isLoading: boolean
isSaving: boolean
}
export const JobspySection: React.FC<JobspySectionProps> = ({
jobspySitesDraft,
setJobspySitesDraft,
defaultJobspySites,
effectiveJobspySites,
jobspyLocationDraft,
setJobspyLocationDraft,
defaultJobspyLocation,
effectiveJobspyLocation,
jobspyResultsWantedDraft,
setJobspyResultsWantedDraft,
defaultJobspyResultsWanted,
effectiveJobspyResultsWanted,
jobspyHoursOldDraft,
setJobspyHoursOldDraft,
defaultJobspyHoursOld,
effectiveJobspyHoursOld,
jobspyCountryIndeedDraft,
setJobspyCountryIndeedDraft,
defaultJobspyCountryIndeed,
effectiveJobspyCountryIndeed,
jobspyLinkedinFetchDescriptionDraft,
setJobspyLinkedinFetchDescriptionDraft,
defaultJobspyLinkedinFetchDescription,
effectiveJobspyLinkedinFetchDescription,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">JobSpy Scraper</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
<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: {effectiveJobspyLocation || "—"}</span>
<span>Default: {defaultJobspyLocation || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
<span>Default: {defaultJobspyResultsWanted}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
<span>Default: {defaultJobspyHoursOld}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
<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: {effectiveJobspyCountryIndeed || "—"}</span>
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="linkedin-desc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Fetch LinkedIn Description
</label>
<p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,125 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type ModelSettingsSectionProps = {
modelDraft: string
setModelDraft: (value: string) => void
modelScorerDraft: string
setModelScorerDraft: (value: string) => void
modelTailoringDraft: string
setModelTailoringDraft: (value: string) => void
modelProjectSelectionDraft: string
setModelProjectSelectionDraft: (value: string) => void
effectiveModel: string
effectiveModelScorer: string
effectiveModelTailoring: string
effectiveModelProjectSelection: string
defaultModel: string
isLoading: boolean
isSaving: boolean
}
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
modelDraft,
setModelDraft,
modelScorerDraft,
setModelScorerDraft,
modelTailoringDraft,
setModelTailoringDraft,
modelProjectSelectionDraft,
setModelProjectSelectionDraft,
effectiveModel,
effectiveModelScorer,
effectiveModelTailoring,
effectiveModelProjectSelection,
defaultModel,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Model</span>
</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
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<Separator />
<div className="space-y-4">
<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
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Global Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,60 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type PipelineWebhookSectionProps = {
pipelineWebhookUrlDraft: string
setPipelineWebhookUrlDraft: (value: string) => void
defaultPipelineWebhookUrl: string
effectivePipelineWebhookUrl: string
isLoading: boolean
isSaving: boolean
}
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
pipelineWebhookUrlDraft,
setPipelineWebhookUrlDraft,
defaultPipelineWebhookUrl,
effectivePipelineWebhookUrl,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</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
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,148 @@
import React from "react"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import { clampInt } from "../utils"
type ResumeProjectsSectionProps = {
resumeProjectsDraft: ResumeProjectsSettings | null
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
isLoading: boolean
isSaving: boolean
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
resumeProjectsDraft,
setResumeProjectsDraft,
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume Projects</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
/>
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,71 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
type SearchTermsSectionProps = {
searchTermsDraft: string[] | null
setSearchTermsDraft: (value: string[] | null) => void
defaultSearchTerms: string[]
effectiveSearchTerms: string[]
isLoading: boolean
isSaving: boolean
}
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
searchTermsDraft,
setSearchTermsDraft,
defaultSearchTerms,
effectiveSearchTerms,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search Terms</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</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">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,70 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
type UkvisajobsSectionProps = {
ukvisajobsMaxJobsDraft: number | null
setUkvisajobsMaxJobsDraft: (value: number | null) => void
defaultUkvisajobsMaxJobs: number
effectiveUkvisajobsMaxJobs: number
isLoading: boolean
isSaving: boolean
}
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
ukvisajobsMaxJobsDraft,
setUkvisajobsMaxJobsDraft,
defaultUkvisajobsMaxJobs,
effectiveUkvisajobsMaxJobs,
isLoading,
isSaving,
}) => {
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
</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>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
<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>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,18 @@
/**
* Settings page constants.
*/
import type { JobStatus } from "@shared/types"
/** All available job statuses for clearing */
export const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
/** Status descriptions for UI */
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
discovered: 'Crawled but not processed',
processing: 'Currently generating resume',
ready: 'PDF generated, waiting for user to apply',
applied: 'User marked as applied',
skipped: 'User skipped this job',
expired: 'Deadline passed',
}

View File

@ -0,0 +1,27 @@
/**
* Settings page helpers.
*/
import type { ResumeProjectsSettings } from "@shared/types"
export function arraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
return (
a.maxProjects === b.maxProjects &&
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
)
}
export function clampInt(value: number, min: number, max: number) {
const int = Math.floor(value)
if (Number.isNaN(int)) return min
return Math.min(max, Math.max(min, int))
}