White-label workspace and getting-started checklist
- Settings → Workspace: agency name, logo, brand colour, support email, website - Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports; brand colour drives buttons, links and highlights; emails and reports use the agency name - Getting started checklist on the Clients page for admins, checked against the real state (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d9c5b8ccdf
commit
82814f3757
@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { safeSlug } from "./clients.js";
|
import { safeSlug } from "./clients.js";
|
||||||
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
||||||
|
import { reportOptions } from "./workspace.js";
|
||||||
|
|
||||||
/** Resolve a document path inside one client's folder (blocks ../ escapes). */
|
/** Resolve a document path inside one client's folder (blocks ../ escapes). */
|
||||||
export function resolveDoc(slug, rel) {
|
export function resolveDoc(slug, rel) {
|
||||||
@ -42,6 +43,6 @@ export function renderDoc(slug, rel) {
|
|||||||
export function exportPdf(slug, rel) {
|
export function exportPdf(slug, rel) {
|
||||||
const full = resolveDoc(slug, rel);
|
const full = resolveDoc(slug, rel);
|
||||||
if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 });
|
if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 });
|
||||||
const written = toPdf(full);
|
const written = toPdf(full, reportOptions());
|
||||||
return path.relative(safeSlug(slug), written).replace(/\\/g, "/");
|
return path.relative(safeSlug(slug), written).replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import * as monthly from "./monthly.js";
|
|||||||
import * as bio from "./bio.js";
|
import * as bio from "./bio.js";
|
||||||
import * as activity from "./activity.js";
|
import * as activity from "./activity.js";
|
||||||
import * as health from "./health.js";
|
import * as health from "./health.js";
|
||||||
|
import * as onboarding from "./onboarding.js";
|
||||||
|
import * as workspace from "./workspace.js";
|
||||||
import * as odoo from "./odoo.js";
|
import * as odoo from "./odoo.js";
|
||||||
import * as jobs from "./jobs.js";
|
import * as jobs from "./jobs.js";
|
||||||
import * as docs from "./docs.js";
|
import * as docs from "./docs.js";
|
||||||
@ -57,7 +59,9 @@ const h = (fn) => async (req, res) => {
|
|||||||
app.post("/api/auth/login", login);
|
app.post("/api/auth/login", login);
|
||||||
app.post("/api/auth/logout", logout);
|
app.post("/api/auth/logout", logout);
|
||||||
app.post("/api/auth/setup", setupFirstAdmin);
|
app.post("/api/auth/setup", setupFirstAdmin);
|
||||||
app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, setupNeeded: !users.hasUsers() }));
|
app.get("/api/auth/me", (req, res) => { const w = workspace.get(); res.json({ user: currentUser(req), agency: workspace.agencyName(), workspace: { accent: w.accent, hasLogo: w.hasLogo }, setupNeeded: !users.hasUsers() }); });
|
||||||
|
// The agency's own logo (white-label), public so the sign-in page and client pages can show it.
|
||||||
|
app.get("/brand/agency-logo", (_req, res) => { const f = workspace.logoPath(); if (!f) return res.status(404).end(); res.setHeader("Cache-Control", "no-cache"); res.sendFile(f); });
|
||||||
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
|
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
|
||||||
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png")));
|
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png")));
|
||||||
app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png")));
|
app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png")));
|
||||||
@ -361,6 +365,16 @@ app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.c
|
|||||||
app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) })));
|
app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) })));
|
||||||
app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) })));
|
app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) })));
|
||||||
|
|
||||||
|
// ---------- workspace (white-label) ----------
|
||||||
|
app.get("/api/workspace", h(async () => workspace.get()));
|
||||||
|
app.put("/api/workspace", requireAdmin, h(async (req) => workspace.save(req.body || {}, who(req))));
|
||||||
|
app.post("/api/workspace/logo", requireAdmin, express.raw({ type: "*/*", limit: "3mb" }), h(async (req) => workspace.saveLogo(req.body, req.headers["x-filename"])));
|
||||||
|
app.delete("/api/workspace/logo", requireAdmin, h(async () => { workspace.removeLogo(); return workspace.get(); }));
|
||||||
|
|
||||||
|
// ---------- getting started checklist ----------
|
||||||
|
app.get("/api/onboarding", requireAdmin, h(async () => onboarding.steps()));
|
||||||
|
app.post("/api/onboarding/dismiss", requireAdmin, h(async (req) => { onboarding.dismiss(req.body?.dismissed !== false); return onboarding.steps(); }));
|
||||||
|
|
||||||
// ---------- admin: audit log and system health ----------
|
// ---------- admin: audit log and system health ----------
|
||||||
app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) })));
|
app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) })));
|
||||||
app.get("/api/admin/health", requireAdmin, h(async () => health.checks()));
|
app.get("/api/admin/health", requireAdmin, h(async () => health.checks()));
|
||||||
@ -552,7 +566,7 @@ if (process.env.SCHEDULER_ENABLED !== "false") {
|
|||||||
|
|
||||||
const host = process.env.DASHBOARD_HOST || "127.0.0.1";
|
const host = process.env.DASHBOARD_HOST || "127.0.0.1";
|
||||||
app.listen(PORT, host, () => {
|
app.listen(PORT, host, () => {
|
||||||
console.log(`${AGENCY_NAME} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`);
|
console.log(`${workspace.agencyName()} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`);
|
||||||
if (!users.hasUsers()) console.log("No logins yet. Open the dashboard to create the first admin.");
|
if (!users.hasUsers()) console.log("No logins yet. Open the dashboard to create the first admin.");
|
||||||
if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps.");
|
if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps.");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
// Written as clients/<slug>/reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client.
|
// Written as clients/<slug>/reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client.
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
import { DATA_DIR } from "./config.js";
|
||||||
|
import { agencyName, reportOptions } from "./workspace.js";
|
||||||
import * as clients from "./clients.js";
|
import * as clients from "./clients.js";
|
||||||
import * as metrics from "./metrics.js";
|
import * as metrics from "./metrics.js";
|
||||||
import * as published from "./published.js";
|
import * as published from "./published.js";
|
||||||
@ -123,7 +124,7 @@ export function build(slug, month = lastMonth()) {
|
|||||||
for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`);
|
for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`);
|
||||||
if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`);
|
if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`);
|
||||||
} else lines.push("We'll share next month's plan with you for approval.");
|
} else lines.push("We'll share next month's plan with you for approval.");
|
||||||
lines.push("", `*Prepared by ${AGENCY_NAME}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, "");
|
lines.push("", `*Prepared by ${agencyName()}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, "");
|
||||||
|
|
||||||
const reportsDir = path.join(dir, "reports");
|
const reportsDir = path.join(dir, "reports");
|
||||||
fs.mkdirSync(reportsDir, { recursive: true });
|
fs.mkdirSync(reportsDir, { recursive: true });
|
||||||
@ -136,7 +137,7 @@ export function build(slug, month = lastMonth()) {
|
|||||||
export function buildWithPdf(slug, month) {
|
export function buildWithPdf(slug, month) {
|
||||||
const md = build(slug, month);
|
const md = build(slug, month);
|
||||||
const dir = clients.safeSlug(slug);
|
const dir = clients.safeSlug(slug);
|
||||||
const pdf = toPdf(path.join(dir, md));
|
const pdf = toPdf(path.join(dir, md), reportOptions());
|
||||||
return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") };
|
return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,7 +154,7 @@ export async function emailReport(slug, month = lastMonth()) {
|
|||||||
subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`,
|
subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`,
|
||||||
heading: `Your ${monthLabel(month)} social media report`,
|
heading: `Your ${monthLabel(month)} social media report`,
|
||||||
lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."],
|
lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."],
|
||||||
fromName: AGENCY_NAME,
|
fromName: agencyName(),
|
||||||
attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }],
|
attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }],
|
||||||
});
|
});
|
||||||
if (!r.sent) throw err(`Couldn't send: ${r.reason}`);
|
if (!r.sent) throw err(`Couldn't send: ${r.reason}`);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import * as notify from "./notify.js";
|
import * as notify from "./notify.js";
|
||||||
import * as portal from "./portal.js";
|
import * as portal from "./portal.js";
|
||||||
import * as clients from "./clients.js";
|
import * as clients from "./clients.js";
|
||||||
import { AGENCY_NAME } from "./config.js";
|
import { agencyName } from "./workspace.js";
|
||||||
import * as sched from "../../connector/src/schedule.js";
|
import * as sched from "../../connector/src/schedule.js";
|
||||||
import * as plan from "../../connector/src/plan.js";
|
import * as plan from "../../connector/src/plan.js";
|
||||||
import * as extra from "../../connector/src/platforms/index.js";
|
import * as extra from "../../connector/src/platforms/index.js";
|
||||||
@ -34,8 +34,8 @@ async function emailClient(slug) {
|
|||||||
"Approve them or tell us what to change. Nothing is posted until our team gives the final go-ahead.",
|
"Approve them or tell us what to change. Nothing is posted until our team gives the final go-ahead.",
|
||||||
],
|
],
|
||||||
button: { label: "Review posts", url: notify.appUrl(`/p/${link.token}`) },
|
button: { label: "Review posts", url: notify.appUrl(`/p/${link.token}`) },
|
||||||
fromName: AGENCY_NAME,
|
fromName: agencyName(),
|
||||||
footer: `${AGENCY_NAME} · You're getting this because you review posts for ${clientName(slug)}.`,
|
footer: `${agencyName()} · You're getting this because you review posts for ${clientName(slug)}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
import { DATA_DIR } from "./config.js";
|
||||||
|
import { agencyName } from "./workspace.js";
|
||||||
import * as users from "./users.js";
|
import * as users from "./users.js";
|
||||||
|
|
||||||
const FILE = path.join(DATA_DIR, "notifications.json");
|
const FILE = path.join(DATA_DIR, "notifications.json");
|
||||||
@ -35,7 +36,7 @@ function html({ heading, lines, button, footer }) {
|
|||||||
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
|
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
|
||||||
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
|
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
|
||||||
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
|
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
|
||||||
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || AGENCY_NAME)}</p></div></body></html>`;
|
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || agencyName())}</p></div></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
|
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
|
||||||
@ -45,7 +46,7 @@ export async function sendEmail({ to, subject, heading, lines, button, footer, f
|
|||||||
if (!to) return { sent: false, reason: "No recipient." };
|
if (!to) return { sent: false, reason: "No recipient." };
|
||||||
try {
|
try {
|
||||||
await m.sendMail({
|
await m.sendMail({
|
||||||
from: { name: fromName || AGENCY_NAME, address: fromAddress() }, to, subject, replyTo,
|
from: { name: fromName || agencyName(), address: fromAddress() }, to, subject, replyTo,
|
||||||
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
|
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
|
||||||
html: html({ heading, lines, button, footer }),
|
html: html({ heading, lines, button, footer }),
|
||||||
attachments,
|
attachments,
|
||||||
|
|||||||
39
dashboard/server/onboarding.js
Normal file
39
dashboard/server/onboarding.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// "Getting started" checklist for admins: each step is checked against the real state, not ticked by hand.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DATA_DIR } from "./config.js";
|
||||||
|
import * as users from "./users.js";
|
||||||
|
import * as clients from "./clients.js";
|
||||||
|
import * as notify from "./notify.js";
|
||||||
|
import * as meta from "../../connector/src/meta.js";
|
||||||
|
import * as sched from "../../connector/src/schedule.js";
|
||||||
|
import * as lib from "../../connector/src/library.js";
|
||||||
|
import * as extra from "../../connector/src/platforms/index.js";
|
||||||
|
|
||||||
|
const FILE = path.join(DATA_DIR, "onboarding.json");
|
||||||
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||||
|
|
||||||
|
export async function steps() {
|
||||||
|
let metaOk = false;
|
||||||
|
try { metaOk = (await meta.listAccounts()).length > 0; } catch { /* not connected */ }
|
||||||
|
const list = clients.listClients();
|
||||||
|
const posts = sched.listPosts({});
|
||||||
|
const portal = (() => { try { return Object.keys(JSON.parse(fs.readFileSync(path.join(DATA_DIR, "portal.json"), "utf8"))).length; } catch { return 0; } })();
|
||||||
|
const workspace = (() => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, "workspace.json"), "utf8")); } catch { return {}; } })();
|
||||||
|
const items = [
|
||||||
|
{ id: "workspace", title: "Name your workspace", text: "Your agency's name and logo appear in the app, on client approval pages and in reports.", done: Boolean(workspace.name), link: "/settings#workspace" },
|
||||||
|
{ id: "meta", title: "Connect Facebook and Instagram", text: "Add the Meta system user token to the server's .env (see the hosting guide). Every client's Page and Instagram account then shows up here.", done: metaOk, link: "/settings" },
|
||||||
|
{ id: "client", title: "Add your first client", text: "Pick their Facebook Page or Instagram account from the list.", done: list.length > 0, link: "/" },
|
||||||
|
{ id: "team", title: "Invite your team", text: "Add designers, editors and managers. Each gets their own login.", done: users.listUsers().length > 1, link: "/settings" },
|
||||||
|
{ id: "platforms", title: "Connect another platform", text: "TikTok, YouTube, Google Business Profile, LinkedIn and more, per client.", done: extra.vault.listConnections({}).length > 0, link: "/connections" },
|
||||||
|
{ id: "email", title: "Set up email", text: "So people get notified and clients get their approval emails and monthly reports.", done: notify.emailConfigured(), link: "/admin/health" },
|
||||||
|
{ id: "library", title: "Upload finished work", text: "Designers upload to the Content library; an admin reviews it.", done: lib.listItems({}).length > 0, link: "/library" },
|
||||||
|
{ id: "post", title: "Schedule and approve a post", text: "Create a draft, approve it, and Quorla publishes at the client's local time.", done: posts.some((p) => ["approved", "published", "partial"].includes(p.status)), link: "/publishing" },
|
||||||
|
{ id: "portal", title: "Give a client their approval link", text: "They approve posts or ask for changes without a login.", done: portal > 0, link: "/" },
|
||||||
|
];
|
||||||
|
return { dismissed: Boolean(read().dismissed), done: items.filter((s) => s.done).length, total: items.length, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(value = true) {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ ...read(), dismissed: Boolean(value), at: new Date().toISOString() }));
|
||||||
|
}
|
||||||
@ -3,7 +3,8 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
import { DATA_DIR } from "./config.js";
|
||||||
|
import { agencyName, logoPath as logoPathFn } from "./workspace.js";
|
||||||
import * as clients from "./clients.js";
|
import * as clients from "./clients.js";
|
||||||
import * as sched from "../../connector/src/schedule.js";
|
import * as sched from "../../connector/src/schedule.js";
|
||||||
|
|
||||||
@ -70,7 +71,7 @@ export function view(slug) {
|
|||||||
media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })),
|
media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })),
|
||||||
review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null },
|
review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null },
|
||||||
}));
|
}));
|
||||||
return { client: c?.name || slug, agency: AGENCY_NAME, posts };
|
return { client: c?.name || slug, agency: agencyName(), logo: Boolean(logoPathFn()), posts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A media file of one of this client's posts that is in review (no other files are reachable). */
|
/** A media file of one of this client's posts that is in review (no other files are reachable). */
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
|
<img id="logo" src="/brand/agency-logo" alt="" hidden style="height:36px;width:auto;max-width:200px;object-fit:contain;margin-bottom:14px;display:block">
|
||||||
<h1 id="title">Posts for your approval</h1>
|
<h1 id="title">Posts for your approval</h1>
|
||||||
<p class="sub" id="sub">Loading…</p>
|
<p class="sub" id="sub">Loading…</p>
|
||||||
</header>
|
</header>
|
||||||
@ -120,6 +121,7 @@
|
|||||||
$("title").textContent = d.client;
|
$("title").textContent = d.client;
|
||||||
$("sub").textContent = "Posts your social media team prepared. Approve them, or tell us what to change.";
|
$("sub").textContent = "Posts your social media team prepared. Approve them, or tell us what to change.";
|
||||||
$("foot").textContent = `Prepared by ${d.agency}. Nothing is posted until your team gives the final go-ahead.`;
|
$("foot").textContent = `Prepared by ${d.agency}. Nothing is posted until your team gives the final go-ahead.`;
|
||||||
|
if (d.logo) { $("logo").alt = d.agency; $("logo").hidden = false; }
|
||||||
const waiting = d.posts.filter((p) => p.review.status === "pending");
|
const waiting = d.posts.filter((p) => p.review.status === "pending");
|
||||||
const done = d.posts.filter((p) => p.review.status !== "pending");
|
const done = d.posts.filter((p) => p.review.status !== "pending");
|
||||||
$("waiting").replaceChildren(...(waiting.length ? waiting.map(card) : [el("div", { class: "card empty" }, "You're all caught up. Nothing is waiting for your approval.")]));
|
$("waiting").replaceChildren(...(waiting.length ? waiting.map(card) : [el("div", { class: "card empty" }, "You're all caught up. Nothing is waiting for your approval.")]));
|
||||||
|
|||||||
53
dashboard/server/workspace.js
Normal file
53
dashboard/server/workspace.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Workspace (white-label): the agency's name, logo, brand colour and support email.
|
||||||
|
// Used in the app (sidebar, sign-in), client approval pages, emails and the PDF reports.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
||||||
|
|
||||||
|
const FILE = path.join(DATA_DIR, "workspace.json");
|
||||||
|
const LOGO = path.join(DATA_DIR, "workspace-logo");
|
||||||
|
const HEX = /^#[0-9a-f]{6}$/i;
|
||||||
|
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||||
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||||
|
|
||||||
|
export function get() {
|
||||||
|
const w = read();
|
||||||
|
return { name: w.name || "", accent: w.accent || "", supportEmail: w.supportEmail || "", website: w.website || "", hasLogo: Boolean(logoPath()), updatedAt: w.updatedAt || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name shown to people: the workspace name, else AGENCY_NAME from .env. */
|
||||||
|
export const agencyName = () => read().name || AGENCY_NAME;
|
||||||
|
|
||||||
|
export function save(patch, by) {
|
||||||
|
const cur = read();
|
||||||
|
const next = { ...cur };
|
||||||
|
if (patch.name !== undefined) next.name = String(patch.name).trim().slice(0, 60);
|
||||||
|
if (patch.accent !== undefined) { if (patch.accent && !HEX.test(patch.accent)) throw err("Brand colour must be like #242142."); next.accent = patch.accent || ""; }
|
||||||
|
if (patch.supportEmail !== undefined) { const e = String(patch.supportEmail).trim(); if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email doesn't look right."); next.supportEmail = e; }
|
||||||
|
if (patch.website !== undefined) { const u = String(patch.website).trim(); if (u && !/^https?:\/\//i.test(u)) throw err("Website must start with https://"); next.website = u; }
|
||||||
|
next.updatedAt = new Date().toISOString(); next.updatedBy = by;
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify(next, null, 1));
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoPath() {
|
||||||
|
for (const ext of [".png", ".jpg"]) if (fs.existsSync(LOGO + ext)) return LOGO + ext;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save an uploaded logo (PNG or JPG, up to 2 MB). */
|
||||||
|
export function saveLogo(buf, filename) {
|
||||||
|
if (!Buffer.isBuffer(buf) || !buf.length) throw err("Choose an image.");
|
||||||
|
if (buf.length > 2 * 1024 * 1024) throw err("Logo must be 2 MB or smaller.");
|
||||||
|
const png = buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
|
const jpg = buf[0] === 0xff && buf[1] === 0xd8;
|
||||||
|
if (!png && !jpg) throw err("Logo must be a PNG or JPG.");
|
||||||
|
removeLogo();
|
||||||
|
fs.writeFileSync(LOGO + (png ? ".png" : ".jpg"), buf);
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLogo() { for (const ext of [".png", ".jpg"]) fs.rmSync(LOGO + ext, { force: true }); }
|
||||||
|
|
||||||
|
/** Options for tools/report-pdf toPdf(): the agency's logo when one is set. */
|
||||||
|
export const reportOptions = () => (logoPath() ? { logoPath: logoPath(), logoAlt: agencyName() } : {});
|
||||||
@ -22,6 +22,16 @@ import Connections from "./pages/Connections.jsx";
|
|||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
export const useAuth = () => useContext(AuthCtx);
|
export const useAuth = () => useContext(AuthCtx);
|
||||||
|
|
||||||
|
/** White-label: the agency's brand colour replaces Quorla's navy for buttons, links and highlights. */
|
||||||
|
function applyAccent(hex) {
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(hex || "")) {
|
||||||
|
root.setProperty("--color-brand", hex);
|
||||||
|
root.setProperty("--color-brand-deep", `color-mix(in srgb, ${hex} 78%, black)`);
|
||||||
|
root.setProperty("--color-brand-soft", `color-mix(in srgb, ${hex} 10%, white)`);
|
||||||
|
} else for (const k of ["--color-brand", "--color-brand-deep", "--color-brand-soft"]) root.removeProperty(k);
|
||||||
|
}
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
||||||
{ to: "/calendar", label: "Content calendar", icon: CalendarDays },
|
{ to: "/calendar", label: "Content calendar", icon: CalendarDays },
|
||||||
@ -39,16 +49,18 @@ const NAV = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function Shell({ children }) {
|
function Shell({ children }) {
|
||||||
const { user, signOut, agency } = useAuth();
|
const { user, signOut, agency, workspace } = useAuth();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="min-h-screen flex">
|
||||||
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
|
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
|
||||||
<div className="px-4 pt-4 pb-3">
|
<div className="px-4 pt-4 pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />
|
{workspace?.hasLogo ? <img src="/brand/agency-logo" alt={agency} className="h-[30px] w-auto max-w-[150px] object-contain" /> : <img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />}
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
</div>
|
</div>
|
||||||
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
{workspace?.hasLogo
|
||||||
|
? <div className="mt-2 text-[10.5px] text-faint">Powered by Quorla</div>
|
||||||
|
: agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main">
|
<nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main">
|
||||||
{NAV.filter((n) => !n.admin || user.role === "admin").map(({ to, label, icon: Icon, end, group }) => [
|
{NAV.filter((n) => !n.admin || user.role === "admin").map(({ to, label, icon: Icon, end, group }) => [
|
||||||
@ -100,7 +112,8 @@ export default function App() {
|
|||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
api("/api/auth/me")
|
api("/api/auth/me")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency });
|
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency, workspace: r.workspace });
|
||||||
|
applyAccent(r.workspace?.accent);
|
||||||
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
|
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
|
||||||
})
|
})
|
||||||
.catch(() => setState({ loading: false, user: null }));
|
.catch(() => setState({ loading: false, user: null }));
|
||||||
@ -119,10 +132,10 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (state.loading) return <Spinner />;
|
if (state.loading) return <Spinner />;
|
||||||
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} onSignedIn={refresh} />;
|
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} hasLogo={state.workspace?.hasLogo} onSignedIn={refresh} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, workspace: state.workspace, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Shell>
|
<Shell>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
39
dashboard/web/src/GettingStarted.jsx
Normal file
39
dashboard/web/src/GettingStarted.jsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { CircleCheck, Circle, X } from "lucide-react";
|
||||||
|
import { api } from "./api.js";
|
||||||
|
import { useAuth } from "./App.jsx";
|
||||||
|
import { cx } from "./ui.jsx";
|
||||||
|
|
||||||
|
/** Admin checklist on the Clients page until every step is done or it's dismissed. */
|
||||||
|
export default function GettingStarted() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [d, setD] = useState(null);
|
||||||
|
useEffect(() => { if (isAdmin) api("/api/onboarding").then(setD).catch(() => {}); }, [isAdmin]);
|
||||||
|
if (!d || d.dismissed || d.done === d.total) return null;
|
||||||
|
const next = d.items.find((s) => !s.done);
|
||||||
|
return (
|
||||||
|
<section className="panel p-4" aria-labelledby="gs-title">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="gs-title" className="text-[15px] font-bold">Getting started</h2>
|
||||||
|
<p className="text-[12.5px] text-muted">{d.done} of {d.total} done. Next: <b className="text-ink-2">{next.title}</b>.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => api("/api/onboarding/dismiss", { method: "POST", body: { dismissed: true } }).then(setD)} className="p-1.5 rounded hover:bg-canvas text-muted" aria-label="Hide the getting started list"><X className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-canvas mt-3 overflow-hidden" aria-hidden="true"><div className="h-full bg-accent" style={{ width: `${(d.done / d.total) * 100}%` }} /></div>
|
||||||
|
<ol className="mt-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-5 gap-y-2.5">
|
||||||
|
{d.items.map((s) => (
|
||||||
|
<li key={s.id} className="flex gap-2.5 items-start">
|
||||||
|
{s.done ? <CircleCheck className="size-[18px] text-ok shrink-0 mt-0.5" aria-label="Done" /> : <Circle className="size-[18px] text-faint shrink-0 mt-0.5" aria-label="To do" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
{s.done ? <span className="text-[13px] text-muted line-through">{s.title}</span>
|
||||||
|
: <Link to={s.link} className={cx("text-[13px] font-medium hover:underline", s.id === next.id ? "text-brand-deep" : "text-ink")}>{s.title}</Link>}
|
||||||
|
{!s.done && <p className="text-[12px] text-muted leading-snug">{s.text}</p>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { Button, ErrorNote, Field } from "../ui.jsx";
|
import { Button, ErrorNote, Field } from "../ui.jsx";
|
||||||
|
|
||||||
export default function Login({ onSignedIn, setupNeeded, agency }) {
|
export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -42,7 +42,7 @@ export default function Login({ onSignedIn, setupNeeded, agency }) {
|
|||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<form onSubmit={submit} className="w-full max-w-[340px]">
|
<form onSubmit={submit} className="w-full max-w-[340px]">
|
||||||
<img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />
|
{hasLogo ? <img src="/brand/agency-logo" alt={agency} className="h-[40px] w-auto max-w-[220px] object-contain mb-8" /> : <img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />}
|
||||||
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
|
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
|
||||||
<p className="text-muted text-[13px] mt-1 mb-6">
|
<p className="text-muted text-[13px] mt-1 mb-6">
|
||||||
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}
|
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { api } from "../api.js";
|
|||||||
import { useAuth } from "../App.jsx";
|
import { useAuth } from "../App.jsx";
|
||||||
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Panel, Spark, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
|
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Panel, Spark, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
|
||||||
import AddClientDialog from "./AddClientDialog.jsx";
|
import AddClientDialog from "./AddClientDialog.jsx";
|
||||||
|
import GettingStarted from "../GettingStarted.jsx";
|
||||||
|
|
||||||
const LEVEL = {
|
const LEVEL = {
|
||||||
bad: { icon: CircleAlert, cls: "text-bad" },
|
bad: { icon: CircleAlert, cls: "text-bad" },
|
||||||
@ -64,6 +65,7 @@ export default function Overview() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ErrorNote error={error} />
|
<ErrorNote error={error} />
|
||||||
|
<GettingStarted />
|
||||||
|
|
||||||
{t && (
|
{t && (
|
||||||
<StatRow className="grid-cols-5">
|
<StatRow className="grid-cols-5">
|
||||||
|
|||||||
@ -212,6 +212,49 @@ function TeamPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspacePanel() {
|
||||||
|
const { refresh } = useAuth();
|
||||||
|
const [w, setW] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
const [v, setV] = useState(0);
|
||||||
|
useEffect(() => { api("/api/workspace").then(setW).catch(setMsg); }, []);
|
||||||
|
if (!w) return null;
|
||||||
|
const set = (k, val) => setW({ ...w, [k]: val });
|
||||||
|
const run = async (what, fn) => { setBusy(what); setMsg(null); try { setW(await fn()); setV((x) => x + 1); refresh(); if (what === "save") setMsg("Saved."); } catch (e) { setMsg(e); } setBusy(null); };
|
||||||
|
const upload = (file) => run("logo", () => fetch("/api/workspace/logo", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/octet-stream", "X-Filename": file.name }, body: file }).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; }));
|
||||||
|
return (
|
||||||
|
<Panel title="Workspace" subtitle="Your agency's name, logo and colour. Used in the app, on client approval pages, in emails and on PDF reports." bodyClass="px-5 py-4 space-y-4">
|
||||||
|
<div id="workspace" className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<Field label="Agency name"><input id="ws-name" className="input" value={w.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. Northstar Digital" /></Field>
|
||||||
|
<Field label="Brand colour" hint="Buttons, links and highlights. Leave empty for Quorla's navy.">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input id="ws-accent" type="color" className="size-9 rounded border border-line" value={w.accent || "#3b3778"} onChange={(e) => set("accent", e.target.value)} aria-label="Brand colour" />
|
||||||
|
<input className="input" value={w.accent} onChange={(e) => set("accent", e.target.value)} placeholder="#3b3778" aria-label="Brand colour hex" />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Support email" hint="Shown to clients as the reply-to address."><input id="ws-email" type="email" className="input" value={w.supportEmail} onChange={(e) => set("supportEmail", e.target.value)} /></Field>
|
||||||
|
<Field label="Website"><input id="ws-web" className="input" value={w.website} onChange={(e) => set("website", e.target.value)} placeholder="https://" /></Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Logo" hint="PNG or JPG, up to 2 MB. A wide logo on a transparent background works best. Replaces the Quorla logo in the app and on reports.">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{w.hasLogo && <img src={`/brand/agency-logo?v=${v}`} alt="Current logo" className="h-10 w-auto max-w-[200px] object-contain border border-line rounded p-1 bg-white" />}
|
||||||
|
<label className="inline-flex items-center h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
|
||||||
|
{busy === "logo" ? "Uploading…" : w.hasLogo ? "Replace logo" : "Upload logo"}
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="sr-only" onChange={(e) => e.target.files[0] && upload(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
{w.hasLogo && <Button size="sm" variant="ghost" loading={busy === "rmlogo"} onClick={() => run("rmlogo", () => api("/api/workspace/logo", { method: "DELETE" }))}>Remove</Button>}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="primary" loading={busy === "save"} onClick={() => run("save", () => api("/api/workspace", { method: "PUT", body: { name: w.name, accent: w.accent, supportEmail: w.supportEmail, website: w.website } }))}>Save workspace</Button>
|
||||||
|
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationsPanel() {
|
function NotificationsPanel() {
|
||||||
const [d, setD] = useState(null);
|
const [d, setD] = useState(null);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
@ -249,6 +292,7 @@ export default function Settings() {
|
|||||||
<h1 className="text-[22px] font-extrabold">Settings</h1>
|
<h1 className="text-[22px] font-extrabold">Settings</h1>
|
||||||
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
|
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
|
||||||
</header>
|
</header>
|
||||||
|
{isAdmin && <WorkspacePanel />}
|
||||||
<AccountPanel />
|
<AccountPanel />
|
||||||
<NotificationsPanel />
|
<NotificationsPanel />
|
||||||
{isAdmin && <TeamPanel />}
|
{isAdmin && <TeamPanel />}
|
||||||
|
|||||||
@ -79,11 +79,14 @@ function heroHtml(meta) {
|
|||||||
</section>${kpis}`;
|
</section>${kpis}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHtml(mdPath) {
|
// opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png.
|
||||||
|
function buildHtml(mdPath, opts = {}) {
|
||||||
const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8"));
|
const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8"));
|
||||||
const body = marked.parse(content, { gfm: true, breaks: true });
|
const body = marked.parse(content, { gfm: true, breaks: true });
|
||||||
const css = readFileSync(join(brandDir, "report.css"), "utf8");
|
const css = readFileSync(join(brandDir, "report.css"), "utf8");
|
||||||
const logo = readFileSync(join(brandDir, "metatroncube-logo.png")).toString("base64");
|
const custom = opts.logoPath && existsSync(opts.logoPath);
|
||||||
|
const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64");
|
||||||
|
const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png";
|
||||||
const h1 = content.match(/^#\s+(.+)$/m)?.[1];
|
const h1 = content.match(/^#\s+(.+)$/m)?.[1];
|
||||||
const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, "");
|
const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, "");
|
||||||
const internal = /-internal/i.test(basename(mdPath));
|
const internal = /-internal/i.test(basename(mdPath));
|
||||||
@ -97,7 +100,9 @@ function buildHtml(mdPath) {
|
|||||||
<style>${css}</style>
|
<style>${css}</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<header class="mc-topbar">
|
<header class="mc-topbar">
|
||||||
<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>
|
${custom
|
||||||
|
? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>`
|
||||||
|
: `<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>`}
|
||||||
<div class="mc-topmeta">
|
<div class="mc-topmeta">
|
||||||
Prepared ${escapeHtml(date)}
|
Prepared ${escapeHtml(date)}
|
||||||
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
|
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
|
||||||
@ -108,7 +113,7 @@ ${meta.client ? heroHtml(meta) : ""}
|
|||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPdf(mdPath) {
|
function toPdf(mdPath, opts = {}) {
|
||||||
const abs = resolve(mdPath);
|
const abs = resolve(mdPath);
|
||||||
const out = abs.replace(/\.md$/i, "") + ".pdf";
|
const out = abs.replace(/\.md$/i, "") + ".pdf";
|
||||||
const work = mkdtempSync(join(tmpdir(), "mc-report-"));
|
const work = mkdtempSync(join(tmpdir(), "mc-report-"));
|
||||||
@ -116,7 +121,7 @@ function toPdf(mdPath) {
|
|||||||
try {
|
try {
|
||||||
const htmlPath = join(work, "report.html");
|
const htmlPath = join(work, "report.html");
|
||||||
const tmpPdf = join(work, "report.pdf");
|
const tmpPdf = join(work, "report.pdf");
|
||||||
writeFileSync(htmlPath, buildHtml(abs));
|
writeFileSync(htmlPath, buildHtml(abs, opts));
|
||||||
execFileSync(findBrowser(), [
|
execFileSync(findBrowser(), [
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
"--disable-gpu",
|
"--disable-gpu",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user