seedproject-web/app/src/lib/projects.js

114 lines
3.3 KiB
JavaScript
Raw Normal View History

import mysql from "mysql2/promise";
import fallback from "../config/projects.json";
/**
* Projects, read from cja_projects at BUILD time.
*
* The site is static, so this runs once during `npm run build` and every
* project page ships as real HTML crawlable, instant, no client-side fetch
* and no loading state. The cost is that content changes need a rebuild, which
* is exactly what the agent pipeline already does.
*
* If the database is unreachable the build does NOT fail: it falls back to
* config/projects.json and logs loudly. A dead database should never be able
* to take the whole site down with it.
*/
const env = import.meta.env;
let cache = null;
const parseJson = (value, whenEmpty) => {
if (!value) return whenEmpty;
if (typeof value === "object") return value; // mysql2 may pre-parse JSON columns
try {
return JSON.parse(value);
} catch {
return whenEmpty;
}
};
/** Shape a DB row into what the templates expect. */
const toProject = (row) => ({
slug: row.slug,
title: row.title,
kind: row.kind,
period: row.period_label,
status: row.status,
summary: row.summary,
lede: row.lede,
body: row.body,
role: row.role,
categories: parseJson(row.categories, []),
stack: parseJson(row.stack, []),
skills: parseJson(row.skills, []),
metrics: parseJson(row.metrics, []),
gallery: parseJson(row.gallery, []),
links: parseJson(row.links, {}),
cover: row.cover_image || null,
coverAlt: row.cover_alt || "",
seoTitle: row.seo_title || null,
seoDescription: row.seo_description || null,
featured: Boolean(row.featured),
});
export async function getProjects() {
if (cache) return cache;
const host = env.DB_HOST;
const database = env.DB_NAME;
const user = env.DB_USER;
const password = env.DB_PASS;
if (!host || !database || !user) {
console.warn(
"[projects] No database credentials in env — falling back to config/projects.json",
);
cache = fallback;
return cache;
}
let conn;
try {
conn = await mysql.createConnection({ host, user, password, database });
const [rows] = await conn.execute(
`SELECT * FROM cja_projects
WHERE published = 1
ORDER BY sort_order ASC, started_on DESC`,
);
if (!rows.length) {
console.warn("[projects] cja_projects returned no published rows — using fallback");
cache = fallback;
} else {
cache = rows.map(toProject);
console.log(`[projects] loaded ${cache.length} from cja_projects`);
}
} catch (error) {
// Loud, but not fatal. A build that silently ships stale content is worse
// than one that tells you the database was unreachable.
console.error(`[projects] DB read failed (${error.message}) — using fallback`);
cache = fallback;
} finally {
await conn?.end();
}
return cache;
}
export async function getProject(slug) {
return (await getProjects()).find((p) => p.slug === slug) ?? null;
}
/** Category names with counts, derived from the projects themselves. */
export async function getCategories() {
const projects = await getProjects();
const counts = {};
for (const p of projects) {
for (const c of p.categories ?? []) counts[c] = (counts[c] ?? 0) + 1;
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([name, count]) => ({ name, count, slug: name.toLowerCase() }));
}