Add star
This commit is contained in:
@@ -73,6 +73,7 @@ WantedBy=multi-user.target
|
||||
## UI
|
||||
|
||||
- **New / Seen / All** tabs. Click *Mark seen* to move a job to Seen (opening a job does not mark it).
|
||||
- Click ☆ to star a job; starred jobs are listed under **Starred** (starring is independent of seen/new).
|
||||
- **no longer listed** means the job was missing from that source's latest successful scrape.
|
||||
- Search filters on title, company and tags. Recent scrape runs (and errors) are listed at the bottom.
|
||||
|
||||
|
||||
+15
-4
@@ -25,9 +25,13 @@ db.run(`
|
||||
first_seen_at TEXT NOT NULL, -- when the scraper first found it
|
||||
last_seen_at TEXT NOT NULL, -- last scrape that still listed it
|
||||
seen_at TEXT, -- when you marked it as seen (NULL = new)
|
||||
starred_at TEXT, -- when you starred it (NULL = not starred)
|
||||
UNIQUE (source, external_id)
|
||||
)
|
||||
`);
|
||||
// No migration system: add columns that older databases are missing.
|
||||
const jobColumns = (db.query("PRAGMA table_info(jobs)").all() as { name: string }[]).map((c) => c.name);
|
||||
if (!jobColumns.includes("starred_at")) db.run("ALTER TABLE jobs ADD COLUMN starred_at TEXT");
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS scrape_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -53,6 +57,7 @@ export interface JobRow {
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
seen_at: string | null;
|
||||
starred_at: string | null;
|
||||
/** 0 if the job was missing from the source's latest successful scrape. */
|
||||
still_listed: number;
|
||||
}
|
||||
@@ -113,13 +118,14 @@ export function finishRun(id: number, result: { found?: number; newCount?: numbe
|
||||
);
|
||||
}
|
||||
|
||||
export type Filter = "new" | "seen" | "all";
|
||||
export type Filter = "new" | "seen" | "starred" | "all";
|
||||
|
||||
export function listJobs(opts: { filter: Filter; source?: string; q?: string }): JobRow[] {
|
||||
const where: string[] = [];
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.filter === "new") where.push("j.seen_at IS NULL");
|
||||
if (opts.filter === "seen") where.push("j.seen_at IS NOT NULL");
|
||||
if (opts.filter === "starred") where.push("j.starred_at IS NOT NULL");
|
||||
if (opts.source) {
|
||||
where.push("j.source = $source");
|
||||
params.source = opts.source;
|
||||
@@ -142,18 +148,23 @@ export function listJobs(opts: { filter: Filter; source?: string; q?: string }):
|
||||
.all(params) as JobRow[];
|
||||
}
|
||||
|
||||
export function counts(): { new: number; seen: number; all: number } {
|
||||
export function counts(): { new: number; seen: number; starred: number; all: number } {
|
||||
return db
|
||||
.query(
|
||||
`SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, COUNT(*) AS "all" FROM jobs`,
|
||||
`SELECT SUM(seen_at IS NULL) AS new, SUM(seen_at IS NOT NULL) AS seen, SUM(starred_at IS NOT NULL) AS starred,
|
||||
COUNT(*) AS "all" FROM jobs`,
|
||||
)
|
||||
.get() as { new: number; seen: number; all: number };
|
||||
.get() as { new: number; seen: number; starred: number; all: number };
|
||||
}
|
||||
|
||||
export function setSeen(id: number, seen: boolean) {
|
||||
db.query("UPDATE jobs SET seen_at = ? WHERE id = ?").run(seen ? new Date().toISOString() : null, id);
|
||||
}
|
||||
|
||||
export function setStarred(id: number, starred: boolean) {
|
||||
db.query("UPDATE jobs SET starred_at = ? WHERE id = ?").run(starred ? new Date().toISOString() : null, id);
|
||||
}
|
||||
|
||||
export function markAllSeen(ids: number[]) {
|
||||
const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
+43
-6
@@ -1,4 +1,15 @@
|
||||
import { counts, lastRunAt, listJobs, markAllSeen, recentRuns, setSeen, sources, type Filter, type JobRow } from "./db";
|
||||
import {
|
||||
counts,
|
||||
lastRunAt,
|
||||
listJobs,
|
||||
markAllSeen,
|
||||
recentRuns,
|
||||
setSeen,
|
||||
setStarred,
|
||||
sources,
|
||||
type Filter,
|
||||
type JobRow,
|
||||
} from "./db";
|
||||
import { isScraping, runAllScrapers } from "./scrape";
|
||||
import { scrapers } from "./scrapers";
|
||||
|
||||
@@ -32,7 +43,7 @@ const sourceName = (id: string) => scrapers.find((s) => s.id === id)?.name ?? id
|
||||
function jobCard(j: JobRow) {
|
||||
const tags = JSON.parse(j.tags) as string[];
|
||||
return `
|
||||
<li class="job ${j.seen_at ? "seen" : "new"} ${j.still_listed ? "" : "gone"}" data-id="${j.id}">
|
||||
<li class="job ${j.seen_at ? "seen" : "new"} ${j.starred_at ? "starred" : ""} ${j.still_listed ? "" : "gone"}" data-id="${j.id}">
|
||||
<div class="main">
|
||||
<div class="title-row">
|
||||
${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`}
|
||||
@@ -48,12 +59,13 @@ function jobCard(j: JobRow) {
|
||||
</div>
|
||||
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
|
||||
</div>
|
||||
<button class="star" type="button" title="${j.starred_at ? "Unstar" : "Star"}" aria-pressed="${!!j.starred_at}">${j.starred_at ? "★" : "☆"}</button>
|
||||
<button class="toggle" type="button">${j.seen_at ? "Mark new" : "Mark seen"}</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function page(url: URL) {
|
||||
const filter = (["new", "seen", "all"].includes(url.searchParams.get("filter") ?? "")
|
||||
const filter = (["new", "seen", "starred", "all"].includes(url.searchParams.get("filter") ?? "")
|
||||
? url.searchParams.get("filter")
|
||||
: "new") as Filter;
|
||||
const source = url.searchParams.get("source") ?? "";
|
||||
@@ -74,9 +86,9 @@ function page(url: URL) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Job Board</title>
|
||||
<style>
|
||||
:root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; }
|
||||
:root { --bg:#f6f7f9; --card:#fff; --fg:#1c1f24; --muted:#6b7280; --border:#e3e6ea; --accent:#2563eb; --new:#16a34a; --gone:#b45309; --tag:#eef1f5; --star:#ca8a04; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; }
|
||||
:root { --bg:#111317; --card:#1a1d23; --fg:#e6e8eb; --muted:#9aa1ab; --border:#2a2e36; --accent:#60a5fa; --new:#4ade80; --gone:#fbbf24; --tag:#252a32; --star:#facc15; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
@@ -111,6 +123,8 @@ function page(url: URL) {
|
||||
.tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
|
||||
.tag { background:var(--tag); font-size:12px; padding:1px 8px; border-radius:999px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.toggle { flex-shrink:0; }
|
||||
.star { flex-shrink:0; font-size:18px; line-height:1; padding:4px 8px; color:var(--muted); }
|
||||
.job.starred .star { color:var(--star); border-color:var(--star); }
|
||||
.empty { text-align:center; color:var(--muted); padding:48px 0; }
|
||||
details { margin-top:32px; color:var(--muted); font-size:13px; }
|
||||
table { border-collapse:collapse; width:100%; margin-top:8px; }
|
||||
@@ -133,6 +147,7 @@ function page(url: URL) {
|
||||
<nav class="tabs">
|
||||
<a href="${link("new")}" class="${filter === "new" ? "active" : ""}">New (<span id="c-new">${c.new ?? 0}</span>)</a>
|
||||
<a href="${link("seen")}" class="${filter === "seen" ? "active" : ""}">Seen (<span id="c-seen">${c.seen ?? 0}</span>)</a>
|
||||
<a href="${link("starred")}" class="${filter === "starred" ? "active" : ""}">★ Starred (<span id="c-starred">${c.starred ?? 0}</span>)</a>
|
||||
<a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a>
|
||||
</nav>
|
||||
<form method="get">
|
||||
@@ -146,7 +161,7 @@ function page(url: URL) {
|
||||
${filter !== "seen" && jobs.some((j) => !j.seen_at) ? `<button id="all-seen" type="button">Mark all shown as seen</button>` : ""}
|
||||
</div>
|
||||
|
||||
${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : "No jobs."}</p>`}
|
||||
${jobs.length ? `<ul class="jobs">${jobs.map(jobCard).join("")}</ul>` : `<p class="empty">${filter === "new" ? "No new jobs. 🎉" : filter === "starred" ? "No starred jobs." : "No jobs."}</p>`}
|
||||
|
||||
<details>
|
||||
<summary>Recent scrape runs</summary>
|
||||
@@ -173,8 +188,23 @@ function page(url: URL) {
|
||||
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
|
||||
if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove();
|
||||
}
|
||||
async function setStarred(li, starred) {
|
||||
const res = await fetch("/api/jobs/" + li.dataset.id + "/star", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ starred }), keepalive: true,
|
||||
});
|
||||
const c = await res.json();
|
||||
document.getElementById("c-starred").textContent = c.starred ?? 0;
|
||||
li.classList.toggle("starred", starred);
|
||||
const btn = li.querySelector(".star");
|
||||
btn.textContent = starred ? "★" : "☆";
|
||||
btn.title = starred ? "Unstar" : "Star";
|
||||
btn.setAttribute("aria-pressed", String(starred));
|
||||
if (filter === "starred" && !starred) li.remove();
|
||||
}
|
||||
document.querySelectorAll(".job").forEach((li) => {
|
||||
li.querySelector(".toggle").addEventListener("click", () => setSeen(li, !li.classList.contains("seen")));
|
||||
li.querySelector(".star").addEventListener("click", () => setStarred(li, !li.classList.contains("starred")));
|
||||
});
|
||||
document.getElementById("all-seen")?.addEventListener("click", async () => {
|
||||
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
|
||||
@@ -209,6 +239,13 @@ Bun.serve({
|
||||
return Response.json(counts());
|
||||
},
|
||||
},
|
||||
"/api/jobs/:id/star": {
|
||||
POST: async (req) => {
|
||||
const { starred } = (await req.json()) as { starred: boolean };
|
||||
setStarred(Number(req.params.id), !!starred);
|
||||
return Response.json(counts());
|
||||
},
|
||||
},
|
||||
"/api/jobs/:id/seen": {
|
||||
POST: async (req) => {
|
||||
const { seen } = (await req.json()) as { seen: boolean };
|
||||
|
||||
Reference in New Issue
Block a user