Add star
This commit is contained in:
@@ -73,6 +73,7 @@ WantedBy=multi-user.target
|
|||||||
## UI
|
## UI
|
||||||
|
|
||||||
- **New / Seen / All** tabs. Click *Mark seen* to move a job to Seen (opening a job does not mark it).
|
- **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.
|
- **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.
|
- 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
|
first_seen_at TEXT NOT NULL, -- when the scraper first found it
|
||||||
last_seen_at TEXT NOT NULL, -- last scrape that still listed it
|
last_seen_at TEXT NOT NULL, -- last scrape that still listed it
|
||||||
seen_at TEXT, -- when you marked it as seen (NULL = new)
|
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)
|
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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS scrape_runs (
|
CREATE TABLE IF NOT EXISTS scrape_runs (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -53,6 +57,7 @@ export interface JobRow {
|
|||||||
first_seen_at: string;
|
first_seen_at: string;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
seen_at: string | null;
|
seen_at: string | null;
|
||||||
|
starred_at: string | null;
|
||||||
/** 0 if the job was missing from the source's latest successful scrape. */
|
/** 0 if the job was missing from the source's latest successful scrape. */
|
||||||
still_listed: number;
|
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[] {
|
export function listJobs(opts: { filter: Filter; source?: string; q?: string }): JobRow[] {
|
||||||
const where: string[] = [];
|
const where: string[] = [];
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (opts.filter === "new") where.push("j.seen_at IS NULL");
|
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 === "seen") where.push("j.seen_at IS NOT NULL");
|
||||||
|
if (opts.filter === "starred") where.push("j.starred_at IS NOT NULL");
|
||||||
if (opts.source) {
|
if (opts.source) {
|
||||||
where.push("j.source = $source");
|
where.push("j.source = $source");
|
||||||
params.source = opts.source;
|
params.source = opts.source;
|
||||||
@@ -142,18 +148,23 @@ export function listJobs(opts: { filter: Filter; source?: string; q?: string }):
|
|||||||
.all(params) as JobRow[];
|
.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
|
return db
|
||||||
.query(
|
.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) {
|
export function setSeen(id: number, seen: boolean) {
|
||||||
db.query("UPDATE jobs SET seen_at = ? WHERE id = ?").run(seen ? new Date().toISOString() : null, id);
|
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[]) {
|
export function markAllSeen(ids: number[]) {
|
||||||
const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL");
|
const stmt = db.query("UPDATE jobs SET seen_at = ? WHERE id = ? AND seen_at IS NULL");
|
||||||
const now = new Date().toISOString();
|
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 { isScraping, runAllScrapers } from "./scrape";
|
||||||
import { scrapers } from "./scrapers";
|
import { scrapers } from "./scrapers";
|
||||||
|
|
||||||
@@ -32,7 +43,7 @@ const sourceName = (id: string) => scrapers.find((s) => s.id === id)?.name ?? id
|
|||||||
function jobCard(j: JobRow) {
|
function jobCard(j: JobRow) {
|
||||||
const tags = JSON.parse(j.tags) as string[];
|
const tags = JSON.parse(j.tags) as string[];
|
||||||
return `
|
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="main">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`}
|
${j.seen_at ? "" : `<span class="badge new-badge">NEW</span>`}
|
||||||
@@ -48,12 +59,13 @@ function jobCard(j: JobRow) {
|
|||||||
</div>
|
</div>
|
||||||
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
|
${tags.length ? `<div class="tags">${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join("")}</div>` : ""}
|
||||||
</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>
|
<button class="toggle" type="button">${j.seen_at ? "Mark new" : "Mark seen"}</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function page(url: URL) {
|
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")
|
? url.searchParams.get("filter")
|
||||||
: "new") as Filter;
|
: "new") as Filter;
|
||||||
const source = url.searchParams.get("source") ?? "";
|
const source = url.searchParams.get("source") ?? "";
|
||||||
@@ -74,9 +86,9 @@ function page(url: URL) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Job Board</title>
|
<title>Job Board</title>
|
||||||
<style>
|
<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) {
|
@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; }
|
* { 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; }
|
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; }
|
.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; }
|
.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; }
|
.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; }
|
.empty { text-align:center; color:var(--muted); padding:48px 0; }
|
||||||
details { margin-top:32px; color:var(--muted); font-size:13px; }
|
details { margin-top:32px; color:var(--muted); font-size:13px; }
|
||||||
table { border-collapse:collapse; width:100%; margin-top:8px; }
|
table { border-collapse:collapse; width:100%; margin-top:8px; }
|
||||||
@@ -133,6 +147,7 @@ function page(url: URL) {
|
|||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="${link("new")}" class="${filter === "new" ? "active" : ""}">New (<span id="c-new">${c.new ?? 0}</span>)</a>
|
<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("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>
|
<a href="${link("all")}" class="${filter === "all" ? "active" : ""}">All (${c.all ?? 0})</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form method="get">
|
<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>` : ""}
|
${filter !== "seen" && jobs.some((j) => !j.seen_at) ? `<button id="all-seen" type="button">Mark all shown as seen</button>` : ""}
|
||||||
</div>
|
</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>
|
<details>
|
||||||
<summary>Recent scrape runs</summary>
|
<summary>Recent scrape runs</summary>
|
||||||
@@ -173,8 +188,23 @@ function page(url: URL) {
|
|||||||
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
|
li.querySelector(".toggle").textContent = seen ? "Mark new" : "Mark seen";
|
||||||
if ((filter === "new" && seen) || (filter === "seen" && !seen)) li.remove();
|
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) => {
|
document.querySelectorAll(".job").forEach((li) => {
|
||||||
li.querySelector(".toggle").addEventListener("click", () => setSeen(li, !li.classList.contains("seen")));
|
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 () => {
|
document.getElementById("all-seen")?.addEventListener("click", async () => {
|
||||||
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
|
const ids = [...document.querySelectorAll(".job.new")].map((li) => Number(li.dataset.id));
|
||||||
@@ -209,6 +239,13 @@ Bun.serve({
|
|||||||
return Response.json(counts());
|
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": {
|
"/api/jobs/:id/seen": {
|
||||||
POST: async (req) => {
|
POST: async (req) => {
|
||||||
const { seen } = (await req.json()) as { seen: boolean };
|
const { seen } = (await req.json()) as { seen: boolean };
|
||||||
|
|||||||
Reference in New Issue
Block a user