Spaces:
Running
Running
Download frontend/src/pages/JobEditor.tsx from EvalBot/Audio-EvalBot: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/EvalBot/Audio-EvalBot/resolve/main/frontend/src/pages/JobEditor.tsx
- Command line
-
hf download hf://spaces/EvalBot/Audio-EvalBot/frontend/src/pages/JobEditor.tsx
-
curl -L -o JobEditor.tsx https://huggingface.co/spaces/EvalBot/Audio-EvalBot/resolve/main/frontend/src/pages/JobEditor.tsx
10.5 kB
| import { ArrowDown, ArrowUp, Plus, Sparkles, Trash2 } from "lucide-react"; | |
| import { useEffect, useState } from "react"; | |
| import { useNavigate, useParams } from "react-router-dom"; | |
| import { Button, ErrorNote, Field, Input, Panel, Select, Spinner, Textarea, useCatalog } from "../components/ui"; | |
| import { api, type Job, type PlanQuestion } from "../lib/api"; | |
| import { useI18n } from "../lib/i18n"; | |
| export default function JobEditor() { | |
| const { id } = useParams(); | |
| const { t, lang } = useI18n(); | |
| const catalog = useCatalog(); | |
| const nav = useNavigate(); | |
| const [loaded, setLoaded] = useState(!id); | |
| const [title, setTitle] = useState(""); | |
| const [track, setTrack] = useState("data_engineering"); | |
| const [level, setLevel] = useState("junior"); | |
| const [desc, setDesc] = useState(""); | |
| const [secs, setSecs] = useState(120); | |
| const [status, setStatus] = useState<Job["status"]>("open"); | |
| const [qs, setQs] = useState<PlanQuestion[]>([{ text: "" }]); | |
| const [location, setLocation] = useState(""); | |
| const [workMode, setWorkMode] = useState<Job["work_mode"]>("onsite"); | |
| const [empType, setEmpType] = useState<Job["employment_type"]>("full_time"); | |
| const [salary, setSalary] = useState(""); | |
| const [skills, setSkills] = useState(""); | |
| const [deadline, setDeadline] = useState(""); | |
| const [visibility, setVisibility] = useState<Job["visibility"]>("public"); | |
| const [requireCamera, setRequireCamera] = useState(false); | |
| const [recordVideo, setRecordVideo] = useState(false); | |
| const [busy, setBusy] = useState(false); | |
| const [suggesting, setSuggesting] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| useEffect(() => { | |
| if (!id) return; | |
| api.get<Job>(`/jobs/${id}`).then((j) => { | |
| setTitle(j.title); setTrack(j.track); setLevel(j.level); setDesc(j.description || ""); | |
| setSecs(j.answer_seconds); setStatus(j.status); setQs(j.questions.length ? j.questions : [{ text: "" }]); | |
| setLocation(j.location || ""); setWorkMode(j.work_mode); setEmpType(j.employment_type); setSalary(j.salary || ""); | |
| setSkills((j.skills || []).join(", ")); setDeadline(j.deadline || ""); setVisibility(j.visibility); | |
| setRequireCamera(!!j.require_camera); setRecordVideo(!!j.record_video); | |
| setLoaded(true); | |
| }); | |
| }, [id]); | |
| const suggest = async () => { | |
| setSuggesting(true); | |
| setError(null); | |
| try { | |
| const r = await api.post<{ questions: PlanQuestion[] }>("/questions/generate", { track, level, interview_type: "mixed", count: 6, custom_role: title, jd_text: desc }); | |
| setQs((cur) => [...cur.filter((x) => x.text.trim()), ...r.questions]); | |
| } catch (e: any) { | |
| setError(e.message); | |
| } finally { | |
| setSuggesting(false); | |
| } | |
| }; | |
| const move = (i: number, d: number) => setQs((cur) => { | |
| const n = [...cur]; | |
| const j = i + d; | |
| if (j < 0 || j >= n.length) return n; | |
| [n[i], n[j]] = [n[j], n[i]]; | |
| return n; | |
| }); | |
| const save = async () => { | |
| setBusy(true); | |
| setError(null); | |
| try { | |
| const body = { | |
| title, track, level, description: desc, answer_seconds: secs, status, questions: qs.filter((x) => x.text.trim()), | |
| location: location.trim() || null, work_mode: workMode, employment_type: empType, salary: salary.trim() || null, | |
| skills: skills.split(/[,،\n]/).map((x) => x.trim()).filter(Boolean), deadline: deadline || null, visibility, | |
| require_camera: requireCamera || recordVideo, record_video: recordVideo, | |
| }; | |
| const j = id ? await api.put<Job>(`/jobs/${id}`, body) : await api.post<Job>("/jobs", body); | |
| nav(`/app/jobs/${j.id}`); | |
| } catch (e: any) { | |
| setError(e.message); | |
| setBusy(false); | |
| } | |
| }; | |
| if (!loaded || !catalog) return <Spinner />; | |
| const levels = ["intern", "junior", "mid", "senior", "lead"]; | |
| return ( | |
| <div className="space-y-6"> | |
| <h1 className="text-3xl font-semibold text-ink">{id ? title : t("new_job")}</h1> | |
| <Panel title={t("job_basics")}> | |
| <div className="grid gap-4 md:grid-cols-2"> | |
| <Field label={t("job_title")}><Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Junior Data Engineer" /></Field> | |
| <Field label={t("track")}><Select value={track} onChange={setTrack} options={catalog.tracks.map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))} /></Field> | |
| <Field label={t("level")}><Select value={level} onChange={setLevel} options={levels.map((l) => ({ value: l, label: t(`lv_${l}` as "lv_junior") }))} /></Field> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <Field label={t("answer_time")}> | |
| <Select value={String(secs)} onChange={(v) => setSecs(+v)} options={[60, 90, 120, 180, 240, 300].map((s) => ({ value: String(s), label: `${s / 60} ${t("min")}` }))} /> | |
| </Field> | |
| <Field label={t("job_status")}> | |
| <Select value={status} onChange={(v) => setStatus(v as Job["status"])} options={(["open", "draft", "closed"] as const).map((s) => ({ value: s, label: t(`jst_${s}`) }))} /> | |
| </Field> | |
| </div> | |
| <Field label={t("location")}><Input value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t("location_ph")} /></Field> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <Field label={t("work_mode")}> | |
| <Select value={workMode} onChange={(v) => setWorkMode(v as Job["work_mode"])} options={(["onsite", "remote", "hybrid"] as const).map((m) => ({ value: m, label: t(`wm_${m}`) }))} /> | |
| </Field> | |
| <Field label={t("employment_type")}> | |
| <Select value={empType} onChange={(v) => setEmpType(v as Job["employment_type"])} options={(["full_time", "part_time", "internship", "contract"] as const).map((m) => ({ value: m, label: t(`et_${m}`) }))} /> | |
| </Field> | |
| </div> | |
| <Field label={`${t("salary")} (${t("optional")})`}><Input value={salary} onChange={(e) => setSalary(e.target.value)} placeholder={t("salary_ph")} /></Field> | |
| <Field label={`${t("deadline_l")} (${t("optional")})`}><Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} /></Field> | |
| <div className="md:col-span-2"><Field label={t("skills_req")}><Input value={skills} onChange={(e) => setSkills(e.target.value)} placeholder={t("skills_ph")} dir="ltr" /></Field></div> | |
| <div className="md:col-span-2"><Field label={t("job_desc")}><Textarea value={desc} onChange={(e) => setDesc(e.target.value)} className="min-h-40" /></Field></div> | |
| <div className="md:col-span-2"> | |
| <span className="mb-1.5 block text-sm font-medium text-ink">{t("visibility")}</span> | |
| <div className="grid gap-2 sm:grid-cols-2"> | |
| {(["public", "link"] as const).map((v) => ( | |
| <button key={v} type="button" onClick={() => setVisibility(v)} aria-pressed={visibility === v} | |
| className={`rounded-xl border px-4 py-3 text-start text-[15px] ${visibility === v ? "border-nile bg-nile-soft text-nile-deep" : "border-line text-ink hover:border-nile"}`}> | |
| {t(v === "public" ? "vis_public" : "vis_link")} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </Panel> | |
| <Panel title={t("job_interview")}> | |
| <div className="grid gap-3 md:grid-cols-2"> | |
| {([[requireCamera || recordVideo, (v: boolean) => { setRequireCamera(v); if (!v) setRecordVideo(false); }, t("require_camera"), t("require_camera_d")], | |
| [recordVideo, (v: boolean) => { setRecordVideo(v); if (v) setRequireCamera(true); }, t("record_video"), t("record_video_d")]] as const).map(([on, set, title, desc]) => ( | |
| <label key={title} className={`flex cursor-pointer items-start gap-3 rounded-xl border p-4 ${on ? "border-nile bg-nile-soft" : "border-line hover:border-nile"}`}> | |
| <input type="checkbox" checked={on} onChange={(e) => set(e.target.checked)} className="mt-1 h-4 w-4 accent-[var(--color-nile)]" /> | |
| <span><span className="block font-medium text-ink">{title}</span><span className="mt-0.5 block text-sm text-muted">{desc}</span></span> | |
| </label> | |
| ))} | |
| </div> | |
| </Panel> | |
| <Panel title={`${t("job_interview")}: ${t("questions")}`} action={<Button variant="outline" onClick={suggest} busy={suggesting}><Sparkles size={16} /> {suggesting ? t("suggesting") : t("suggest")}</Button>}> | |
| <ol className="space-y-3"> | |
| {qs.map((q, i) => ( | |
| <li key={i} className="flex items-start gap-2"> | |
| <span className="mt-2.5 w-6 flex-none text-center text-sm tabular text-muted">{i + 1}</span> | |
| <Textarea value={q.text} onChange={(e) => setQs((cur) => cur.map((x, k) => (k === i ? { ...x, text: e.target.value } : x)))} | |
| placeholder={t("question_ph")} className="ltr min-h-16" dir="ltr" /> | |
| <div className="flex flex-col"> | |
| <button onClick={() => move(i, -1)} className="rounded p-1 text-muted hover:bg-sunken" aria-label="Move up"><ArrowUp size={16} /></button> | |
| <button onClick={() => move(i, 1)} className="rounded p-1 text-muted hover:bg-sunken" aria-label="Move down"><ArrowDown size={16} /></button> | |
| </div> | |
| <button onClick={() => setQs((cur) => cur.filter((_, k) => k !== i))} className="mt-1 rounded-lg p-2 text-muted hover:bg-danger-soft hover:text-danger" aria-label={t("delete")}> | |
| <Trash2 size={16} /> | |
| </button> | |
| </li> | |
| ))} | |
| </ol> | |
| <Button variant="quiet" className="mt-3" onClick={() => setQs((cur) => [...cur, { text: "" }])}><Plus size={16} /> {t("add_question")}</Button> | |
| </Panel> | |
| {error && <ErrorNote>{error}</ErrorNote>} | |
| <div className="flex flex-wrap justify-end gap-3"> | |
| {id && ( | |
| <Button variant="quiet" className="me-auto text-danger hover:bg-danger-soft" onClick={async () => { | |
| if (!window.confirm(t("delete_job_confirm"))) return; | |
| try { await api.del(`/jobs/${id}`); nav("/app/jobs"); } catch (e: any) { setError(e.message); } | |
| }}><Trash2 size={16} /> {t("delete_job")}</Button> | |
| )} | |
| <Button variant="quiet" onClick={() => nav(-1)}>{t("cancel")}</Button> | |
| <Button size="lg" onClick={save} busy={busy} disabled={title.trim().length < 2 || !qs.some((x) => x.text.trim())}>{t("save_job")}</Button> | |
| </div> | |
| </div> | |
| ); | |
| } | |