Initial commit for UI

This commit is contained in:
DaKheera47 2026-01-22 18:57:11 +00:00
parent 1ca459ec34
commit 7f7e76dc3f
8 changed files with 400 additions and 6 deletions

View File

@ -16,8 +16,6 @@ services:
- ./data:/app/data - ./data:/app/data
# Base resume JSON (read-only) # Base resume JSON (read-only)
- ./resume-generator/base.json:/app/resume-generator/base.json:ro - ./resume-generator/base.json:/app/resume-generator/base.json:ro
env_file:
- .env
environment: environment:
# Server config # Server config
- NODE_ENV=production - NODE_ENV=production

View File

@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
import { OnboardingGate } from "./components/OnboardingGate";
export const App: React.FC = () => { export const App: React.FC = () => {
const location = useLocation(); const location = useLocation();
@ -27,6 +28,7 @@ export const App: React.FC = () => {
return ( return (
<> <>
<OnboardingGate />
<SwitchTransition mode="out-in"> <SwitchTransition mode="out-in">
<CSSTransition <CSSTransition
key={pageKey} key={pageKey}

View File

@ -8,7 +8,6 @@ import type {
JobsListResponse, JobsListResponse,
PipelineStatusResponse, PipelineStatusResponse,
JobSource, JobSource,
PipelineRun,
AppSettings, AppSettings,
ResumeProjectsSettings, ResumeProjectsSettings,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
@ -21,6 +20,7 @@ import type {
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
VisaSponsor, VisaSponsor,
ResumeProfile, ResumeProfile,
ProfileStatusResponse,
} from '../../shared/types'; } from '../../shared/types';
import { trackEvent } from "@/lib/analytics"; import { trackEvent } from "@/lib/analytics";
@ -179,6 +179,16 @@ export async function getProfile(): Promise<ResumeProfile> {
return fetchApi<ResumeProfile>('/profile'); return fetchApi<ResumeProfile>('/profile');
} }
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/status');
}
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
return fetchApi<ProfileStatusResponse>('/profile/upload', {
method: 'POST',
body: JSON.stringify({ profile }),
});
}
export async function updateSettings(update: { export async function updateSettings(update: {
model?: string | null model?: string | null

View File

@ -0,0 +1,339 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import * as api from "@client/api"
import { useSettings } from "@client/hooks/useSettings"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils"
import type { ProfileStatusResponse, ResumeProfile } from "@shared/types"
type RequirementRowProps = {
label: string
helper?: string
complete: boolean
}
const RequirementRow: React.FC<RequirementRowProps> = ({ label, helper, complete }) => (
<div className="flex items-start justify-between gap-4 rounded-lg border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{label}</p>
{helper && <p className="text-xs text-muted-foreground">{helper}</p>}
</div>
<Badge
variant={complete ? "secondary" : "outline"}
className={cn("uppercase tracking-[0.18em] text-[0.6rem]", complete ? "text-foreground" : "text-muted-foreground")}
>
{complete ? "Ready" : "Next"}
</Badge>
</div>
)
export const OnboardingGate: React.FC = () => {
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
const [profileStatus, setProfileStatus] = useState<ProfileStatusResponse | null>(null)
const [isCheckingProfile, setIsCheckingProfile] = useState(false)
const [isSavingEnv, setIsSavingEnv] = useState(false)
const [isUploadingResume, setIsUploadingResume] = useState(false)
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [resumeFile, setResumeFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const refreshProfileStatus = useCallback(async () => {
setIsCheckingProfile(true)
try {
const status = await api.getProfileStatus()
setProfileStatus(status)
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to check base resume"
setProfileStatus({ exists: false, error: message })
} finally {
setIsCheckingProfile(false)
}
}, [])
useEffect(() => {
void refreshProfileStatus()
}, [refreshProfileStatus])
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
const hasRxresumeCredentials = hasRxresumeEmail && hasRxresumePassword
const hasBaseResume = Boolean(profileStatus?.exists)
const shouldOpen = Boolean(settings && profileStatus && !settingsLoading && !isCheckingProfile)
&& !(hasOpenrouterKey && hasRxresumeCredentials && hasBaseResume)
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
: undefined
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail
: undefined
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings.rxresumePasswordHint)
: undefined
const handleRefresh = async () => {
const results = await Promise.allSettled([refreshSettings(), refreshProfileStatus()])
const failed = results.find((result) => result.status === "rejected")
if (failed) {
const reason = failed.status === "rejected" ? failed.reason : null
const message = reason instanceof Error ? reason.message : "Failed to refresh setup"
toast.error(message)
}
}
const handleSaveCredentials = async () => {
if (!settings) return
const update: { openrouterApiKey?: string; rxresumeEmail?: string; rxresumePassword?: string } = {}
const openrouterValue = openrouterApiKey.trim()
const emailValue = rxresumeEmail.trim()
const passwordValue = rxresumePassword.trim()
const missing: string[] = []
if (!hasOpenrouterKey && !openrouterValue) {
missing.push("OpenRouter API key")
}
if (!hasRxresumeCredentials) {
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
}
if (missing.length > 0) {
toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`,
})
return
}
if (openrouterValue) update.openrouterApiKey = openrouterValue
if (emailValue) update.rxresumeEmail = emailValue
if (passwordValue) update.rxresumePassword = passwordValue
if (Object.keys(update).length === 0) {
toast.info("Nothing new to save")
return
}
try {
setIsSavingEnv(true)
await api.updateSettings(update)
await refreshSettings()
setOpenrouterApiKey("")
setRxresumePassword("")
toast.success("Credentials saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save credentials"
toast.error(message)
} finally {
setIsSavingEnv(false)
}
}
const handleUploadResume = async () => {
if (!resumeFile) {
toast.info("Choose your base.json file")
return
}
try {
setIsUploadingResume(true)
const text = await resumeFile.text()
let parsed: ResumeProfile
try {
parsed = JSON.parse(text) as ResumeProfile
} catch {
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
}
await api.uploadProfile(parsed)
await refreshProfileStatus()
setResumeFile(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
toast.success("Resume uploaded")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to upload resume"
toast.error(message)
} finally {
setIsUploadingResume(false)
}
}
const resumeFileName = resumeFile?.name || ""
const checklist = useMemo(
() => [
{
label: "OpenRouter API key",
helper: "Needed for scoring + tailoring",
complete: hasOpenrouterKey,
},
{
label: "RxResume credentials",
helper: "Used to export PDFs",
complete: hasRxresumeCredentials,
},
{
label: "Base resume JSON",
helper: "Upload resume-generator/base.json",
complete: hasBaseResume,
},
],
[hasBaseResume, hasOpenrouterKey, hasRxresumeCredentials]
)
if (!shouldOpen) return null
return (
<AlertDialog open>
<AlertDialogContent
className="max-w-2xl max-h-[85vh] overflow-y-auto"
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Lets get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">Quick setup checklist</p>
<Button variant="ghost" size="sm" onClick={handleRefresh} disabled={settingsLoading || isCheckingProfile}>
Refresh status
</Button>
</div>
<div className="space-y-2">
{checklist.map((item) => (
<RequirementRow key={item.label} {...item} />
))}
</div>
</div>
<Separator />
<div className="space-y-4">
<div>
<p className="text-sm font-semibold">OpenRouter</p>
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
</div>
<SettingsInput
label="OpenRouter API key"
inputProps={{
name: "openrouterApiKey",
value: openrouterApiKey,
onChange: (event) => setOpenrouterApiKey(event.target.value),
}}
type="password"
placeholder="sk-or-v1..."
current={openrouterCurrent}
helper="Create a key at openrouter.ai"
disabled={isSavingEnv}
/>
</div>
<Separator />
<div className="space-y-4">
<div>
<p className="text-sm font-semibold">RxResume account</p>
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: rxresumeEmail,
onChange: (event) => setRxresumeEmail(event.target.value),
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: rxresumePassword,
onChange: (event) => setRxresumePassword(event.target.value),
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCredentials} disabled={isSavingEnv}>
{isSavingEnv ? "Saving..." : "Save and continue"}
</Button>
</div>
</div>
<Separator />
<div className="space-y-4">
<div>
<p className="text-sm font-semibold">Base resume JSON</p>
<p className="text-xs text-muted-foreground">Upload your RxResume export named base.json.</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label htmlFor="resumeFile" className="text-sm font-medium">
base.json
</label>
<Input
id="resumeFile"
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
disabled={isUploadingResume}
/>
{resumeFileName && (
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
)}
</div>
<Button onClick={handleUploadResume} disabled={isUploadingResume}>
{isUploadingResume ? "Uploading..." : "Upload resume"}
</Button>
</div>
</div>
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and
we will take a look.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
target="_blank"
rel="noreferrer"
>
Open an issue
</a>
.
</div>
</div>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { access, mkdir, writeFile } from 'fs/promises';
import { dirname } from 'path';
import { extractProjectsFromProfile } from '../../services/resumeProjects.js'; import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
import { getProfile } from '../../services/profile.js'; import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js';
export const profileRouter = Router(); export const profileRouter = Router();
@ -30,3 +32,38 @@ profileRouter.get('/', async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}); });
/**
* GET /api/profile/status - Check if base resume exists
*/
profileRouter.get('/status', async (_req: Request, res: Response) => {
try {
await access(DEFAULT_PROFILE_PATH);
res.json({ success: true, data: { exists: true, error: null } });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.json({ success: true, data: { exists: false, error: message } });
}
});
/**
* POST /api/profile/upload - Upload base resume JSON
*/
profileRouter.post('/upload', async (req: Request, res: Response) => {
try {
const profile = (req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>).profile : null) as unknown;
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
throw new Error('Invalid profile payload. Expected a JSON object.');
}
await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true });
await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(profile, null, 2), 'utf-8');
clearProfileCache();
res.json({ success: true, data: { exists: true, error: null } });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: message });
}
});

View File

@ -74,7 +74,7 @@ export function createApp() {
const authGuard = createBasicAuthGuard(); const authGuard = createBasicAuthGuard();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '5mb' }));
// Logging middleware // Logging middleware
app.use((req, res, next) => { app.use((req, res, next) => {

View File

@ -11,7 +11,10 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
// Parallelize slow operations // Parallelize slow operations
const [overrides, profile] = await Promise.all([ const [overrides, profile] = await Promise.all([
settingsRepo.getAllSettings(), settingsRepo.getAllSettings(),
getProfile(), getProfile().catch((error) => {
console.warn('Failed to load base resume profile for settings:', error);
return {};
}),
]); ]);
const envSettings = await getEnvSettingsData(overrides); const envSettings = await getEnvSettingsData(overrides);

View File

@ -331,6 +331,11 @@ export interface ResumeProfile {
[key: string]: any; [key: string]: any;
} }
export interface ProfileStatusResponse {
exists: boolean;
error: string | null;
}
export interface AppSettings { export interface AppSettings {
model: string; model: string;
defaultModel: string; defaultModel: string;