Initial import of hire-me.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
delikesance
2026-09-04 20:08:12 +00:00
co-authored by Cursor
commit 9c734470b3
23 changed files with 2463 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
const statusEl = document.querySelector("#status");
const listEl = document.querySelector("#list");
const metaEl = document.querySelector("#meta");
const dialog = document.querySelector("#preview");
const previewTitle = document.querySelector("#preview-title");
const previewFrom = document.querySelector("#preview-from");
const previewReply = document.querySelector("#preview-reply");
const previewAttach = document.querySelector("#preview-attach");
const previewTo = document.querySelector("#preview-to");
const previewSubject = document.querySelector("#preview-subject");
const previewText = document.querySelector("#preview-text");
const previewError = document.querySelector("#preview-error");
const previewSend = document.querySelector("#preview-send");
const previewRegen = document.querySelector("#preview-regen");
/** @type {{ offer: any, article: HTMLElement, button: HTMLButtonElement, input: HTMLInputElement, hint: HTMLElement | null, feedback: HTMLElement, language: string } | null} */
let active = null;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function sourceLabel(source) {
if (source === "website") return "trouvé sur le site";
if (source === "gemini") return "détecté via Gemini";
if (source === "guess") return "estimé (careers/recruiting@)";
if (source === "description") return "dans loffre";
if (source === "manual") return "saisi";
if (source === "cache") return "en cache";
return source || "recherche…";
}
function showPreviewError(message) {
if (!previewError) return;
previewError.hidden = !message;
previewError.textContent = message || "";
}
async function loadPreview(forceTo) {
if (!active) return;
const { offer, button, input, feedback } = active;
showPreviewError("");
button.disabled = true;
button.textContent = "Rédaction…";
feedback.hidden = false;
feedback.className = "feedback";
feedback.textContent = "Gemini rédige le mail…";
if (previewRegen) previewRegen.disabled = true;
if (previewSend) previewSend.disabled = true;
try {
const response = await fetch("/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: offer.id,
to: forceTo || input.value.trim() || undefined,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Impossible de préparer le mail");
active.language = data.language || "en";
previewTitle.textContent = `${data.company}${data.title}`;
previewFrom.textContent = data.from;
previewReply.textContent = data.replyTo;
previewAttach.textContent = data.attachment;
previewTo.value = data.to;
previewSubject.value = data.subject;
previewText.value = data.text;
input.value = data.to;
if (active.hint) active.hint.textContent = sourceLabel(data.emailSource);
feedback.hidden = true;
dialog?.showModal();
} catch (error) {
feedback.className = "feedback err";
feedback.textContent = error instanceof Error ? error.message : "Erreur";
} finally {
button.disabled = false;
button.textContent = "Postuler";
if (previewRegen) previewRegen.disabled = false;
if (previewSend) previewSend.disabled = false;
}
}
async function sendPreview() {
if (!active) return;
const { offer, article, button, input, hint, feedback } = active;
const to = previewTo.value.trim();
const subject = previewSubject.value.trim();
const text = previewText.value.trim();
showPreviewError("");
if (!to || !subject || !text) {
showPreviewError("Destinataire, objet et corps sont requis.");
return;
}
previewSend.disabled = true;
previewRegen.disabled = true;
previewSend.textContent = "Envoi…";
try {
const response = await fetch("/api/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: offer.id,
to,
subject,
text,
language: active.language,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Échec de candidature");
article.classList.add("applied");
button.classList.add("done");
button.textContent = "Envoyé";
button.disabled = true;
input.value = data.to;
input.disabled = true;
if (hint) hint.textContent = sourceLabel(data.emailSource);
feedback.hidden = false;
feedback.className = "feedback ok";
feedback.textContent = `Envoyé à ${data.to}${data.subject}`;
dialog?.close();
} catch (error) {
showPreviewError(error instanceof Error ? error.message : "Erreur");
} finally {
previewSend.disabled = false;
previewRegen.disabled = false;
previewSend.textContent = "Envoyer";
}
}
function renderOffer(offer) {
const applied = Boolean(offer.appliedAt);
const email = offer.emails[0] ?? "";
const article = document.createElement("article");
article.className = `offer${applied ? " applied" : ""}`;
article.dataset.id = offer.id;
article.innerHTML = `
<div class="offer-head">
<div>
<p class="company">${escapeHtml(offer.company)}</p>
<h2 class="title">${escapeHtml(offer.title)}</h2>
</div>
<span class="score">score ${escapeHtml(offer.score)}</span>
</div>
<p class="meta-row">
<span>${escapeHtml(offer.location)}</span>
<span>·</span>
<span>${escapeHtml(offer.source)}</span>
<span>·</span>
<a href="${escapeHtml(offer.url)}" target="_blank" rel="noreferrer">voir loffre</a>
</p>
<ul class="reasons">
${offer.reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<p class="excerpt">${escapeHtml(offer.excerpt)}</p>
<div class="actions">
<label>
Email destinataire
<input
type="email"
name="to"
value="${escapeHtml(email)}"
placeholder="${email ? "" : "Recherche auto en cours…"}"
${applied ? "disabled" : ""}
/>
<span class="email-hint">${
email
? escapeHtml(sourceLabel(offer.emailSource))
: "sera récupéré automatiquement au clic si besoin"
}</span>
</label>
<button type="button" ${applied ? "disabled" : ""}>
${applied ? "Déjà postulé" : "Postuler"}
</button>
</div>
<p class="feedback" hidden></p>
`;
const button = article.querySelector("button");
const input = article.querySelector('input[name="to"]');
const hint = article.querySelector(".email-hint");
const feedback = article.querySelector(".feedback");
button?.addEventListener("click", async () => {
if (!button || !input || !feedback) return;
active = { offer, article, button, input, hint, feedback, language: "en" };
await loadPreview(input.value.trim());
});
return article;
}
previewSend?.addEventListener("click", () => {
void sendPreview();
});
previewRegen?.addEventListener("click", () => {
void loadPreview(previewTo?.value.trim());
});
async function boot() {
try {
const response = await fetch("/api/offers");
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Impossible de charger les offres");
statusEl.hidden = true;
listEl.hidden = false;
metaEl.hidden = false;
const withEmail = data.offers.filter((o) => o.emails?.length).length;
metaEl.textContent = `${data.count} offre(s) — ${withEmail} email(s) trouvés. Postuler ouvre dabord un aperçu.`;
if (!data.offers.length) {
statusEl.hidden = false;
statusEl.textContent = "Aucune offre. Lance dabord `npm run find`.";
return;
}
for (const offer of data.offers) {
listEl.append(renderOffer(offer));
}
setTimeout(async () => {
try {
const again = await fetch("/api/offers");
const next = await again.json();
if (!again.ok) return;
for (const offer of next.offers) {
const article = listEl.querySelector(`[data-id="${offer.id}"]`);
const input = article?.querySelector('input[name="to"]');
const hint = article?.querySelector(".email-hint");
if (input && !input.disabled && offer.emails?.[0] && !input.value) {
input.value = offer.emails[0];
if (hint) hint.textContent = sourceLabel(offer.emailSource);
}
}
const filled = next.offers.filter((o) => o.emails?.length).length;
metaEl.textContent = `${next.count} offre(s) — ${filled} email(s) trouvés. Postuler ouvre dabord un aperçu.`;
} catch {
// ignore
}
}, 8000);
} catch (error) {
statusEl.classList.add("error");
statusEl.textContent = error instanceof Error ? error.message : "Erreur de chargement";
}
}
boot();
+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hire-me — candidatures</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="bg"></div>
<main>
<header class="top">
<p class="brand">hire-me</p>
<h1>Offres alignées sur ton profil</h1>
<p class="lede">
<strong>Postuler</strong> prépare le mail (destinataire + texte Gemini + CV). Tu
previews, tu ajustes, puis tu envoies.
</p>
<p id="meta" class="meta" hidden></p>
</header>
<section id="status" class="status" aria-live="polite">Chargement des offres…</section>
<section id="list" class="list" hidden></section>
</main>
<dialog id="preview" class="preview">
<form method="dialog" id="preview-form" class="preview-sheet">
<header class="preview-top">
<div>
<p class="preview-kicker">Aperçu avant envoi</p>
<h2 id="preview-title">Candidature</h2>
</div>
<button type="submit" value="cancel" class="ghost" id="preview-close">Fermer</button>
</header>
<div class="preview-meta">
<p><span>De</span> <strong id="preview-from"></strong></p>
<p><span>Reply-To</span> <strong id="preview-reply"></strong></p>
<p><span>Pièce jointe</span> <strong id="preview-attach"></strong></p>
</div>
<label>
Destinataire
<input type="email" id="preview-to" required />
</label>
<label>
Objet
<input type="text" id="preview-subject" required />
</label>
<label>
Corps du mail
<textarea id="preview-text" rows="14" required></textarea>
</label>
<p id="preview-error" class="feedback err" hidden></p>
<footer class="preview-actions">
<button type="button" class="ghost" id="preview-regen">Regénérer</button>
<div class="preview-actions-right">
<button type="submit" value="cancel" class="ghost">Annuler</button>
<button type="button" id="preview-send">Envoyer</button>
</div>
</footer>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
:root {
--ink: #14201a;
--muted: #5a6b62;
--line: rgba(20, 32, 26, 0.12);
--paper: #f3efe6;
--panel: rgba(255, 252, 246, 0.78);
--accent: #0f6b4c;
--accent-ink: #f7fff9;
--warn: #8a4b16;
--danger: #8f2d2d;
--ok: #1f6b3a;
--shadow: 0 18px 50px rgba(20, 32, 26, 0.08);
--radius: 18px;
--font: "DM Sans", system-ui, sans-serif;
--display: "Instrument Serif", Georgia, serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: var(--font);
color: var(--ink);
background: var(--paper);
line-height: 1.45;
}
.bg {
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(900px 480px at 12% -10%, rgba(15, 107, 76, 0.18), transparent 60%),
radial-gradient(700px 420px at 100% 0%, rgba(138, 75, 22, 0.12), transparent 55%),
linear-gradient(180deg, #f7f3ea 0%, #ebe4d6 100%);
}
main {
width: min(920px, calc(100% - 2rem));
margin: 0 auto;
padding: 2.5rem 0 4rem;
}
.top {
margin-bottom: 1.75rem;
}
.brand {
margin: 0 0 0.75rem;
font-size: 0.85rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0;
font-family: var(--display);
font-weight: 400;
font-size: clamp(2.2rem, 5vw, 3.4rem);
letter-spacing: -0.02em;
line-height: 1.05;
}
.lede {
margin: 0.85rem 0 0;
max-width: 38rem;
color: var(--muted);
font-size: 1.02rem;
}
.meta {
margin: 0.85rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.status {
padding: 1rem 1.15rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
backdrop-filter: blur(10px);
color: var(--muted);
}
.status.error {
color: var(--danger);
border-color: rgba(143, 45, 45, 0.25);
}
.list {
display: grid;
gap: 0.9rem;
}
.offer {
display: grid;
gap: 0.95rem;
padding: 1.15rem 1.2rem 1.2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
backdrop-filter: blur(10px);
box-shadow: var(--shadow);
transition: transform 160ms ease, border-color 160ms ease;
}
.offer:hover {
transform: translateY(-1px);
border-color: rgba(15, 107, 76, 0.28);
}
.offer.applied {
opacity: 0.72;
}
.offer-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.company {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
.title {
margin: 0.25rem 0 0;
font-family: var(--display);
font-size: clamp(1.35rem, 2.5vw, 1.7rem);
line-height: 1.15;
font-weight: 400;
}
.score {
flex: none;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: rgba(15, 107, 76, 0.1);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.75rem;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.meta-row a {
color: inherit;
}
.reasons {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.reasons li {
padding: 0.22rem 0.55rem;
border-radius: 999px;
background: rgba(20, 32, 26, 0.05);
color: var(--muted);
font-size: 0.78rem;
}
.excerpt {
margin: 0;
color: var(--ink);
font-size: 0.95rem;
}
.actions {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.65rem;
align-items: end;
}
label {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.02em;
}
.email-hint {
font-size: 0.75rem;
font-weight: 500;
color: var(--muted);
opacity: 0.9;
}
input[type="email"] {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font: inherit;
}
input[type="email"]:focus {
outline: 2px solid rgba(15, 107, 76, 0.35);
border-color: transparent;
}
button {
appearance: none;
border: 0;
border-radius: 12px;
padding: 0.78rem 1.15rem;
background: var(--accent);
color: var(--accent-ink);
font: inherit;
font-weight: 650;
cursor: pointer;
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.done {
background: var(--ok);
}
.feedback {
margin: 0;
font-size: 0.88rem;
color: var(--muted);
}
.feedback.ok {
color: var(--ok);
}
.feedback.err {
color: var(--danger);
}
.preview {
border: 0;
padding: 0;
max-width: min(720px, calc(100vw - 1.5rem));
width: 100%;
background: transparent;
}
.preview::backdrop {
background: rgba(18, 28, 22, 0.45);
backdrop-filter: blur(6px);
}
.preview-sheet {
display: grid;
gap: 0.85rem;
margin: 0;
padding: 1.2rem 1.25rem 1.25rem;
border: 1px solid var(--line);
border-radius: 22px;
background: #fffcf6;
box-shadow: var(--shadow);
}
.preview-top {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.preview-kicker {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
.preview-top h2 {
margin: 0.2rem 0 0;
font-family: var(--display);
font-size: clamp(1.35rem, 3vw, 1.75rem);
font-weight: 400;
line-height: 1.15;
}
.preview-meta {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0.85rem;
border-radius: 14px;
background: rgba(20, 32, 26, 0.04);
color: var(--muted);
font-size: 0.88rem;
}
.preview-meta p {
margin: 0;
}
.preview-meta span {
display: inline-block;
min-width: 5.5rem;
font-weight: 600;
}
.preview-sheet input[type="text"],
.preview-sheet input[type="email"],
.preview-sheet textarea {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font: inherit;
resize: vertical;
}
.preview-sheet textarea {
line-height: 1.5;
min-height: 220px;
}
.preview-sheet input:focus,
.preview-sheet textarea:focus {
outline: 2px solid rgba(15, 107, 76, 0.35);
border-color: transparent;
}
.preview-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.preview-actions-right {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
button.ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
button.ghost:hover:not(:disabled) {
background: rgba(20, 32, 26, 0.04);
}
@media (max-width: 640px) {
.actions {
grid-template-columns: 1fr;
}
button {
width: 100%;
}
.preview-actions,
.preview-actions-right {
width: 100%;
}
.preview-actions-right button {
flex: 1;
}
}