File size: 6,567 Bytes
0bc2c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
 * Shared page chrome for all MesmerTools HuggingFace Spaces.
 *
 * `createSpace()` builds the header, hero, a card with a <form> + output slot,
 * a status area (errors + the rate-limit cross-sell banner), and the footer
 * "more free tools" strip (the SEO backlinks). A tool space only has to fill
 * the returned `form` and `output` elements and wire its submit handler.
 */

import { SITE, ENDPOINTS, TOOL_CATALOG, siteUrl } from "./config.js";
import { RateLimitError } from "./api-client.js";

/** Tiny hyperscript helper. `props` supports className, text, html, on*, attrs, dataset, style. */
export function el(tag, props = {}, ...children) {
  const node = document.createElement(tag);
  for (const [key, value] of Object.entries(props || {})) {
    if (value == null) continue;
    if (key === "className") node.className = value;
    else if (key === "text") node.textContent = value;
    else if (key === "html") node.innerHTML = value;
    else if (key === "dataset") Object.assign(node.dataset, value);
    else if (key === "style" && typeof value === "object") Object.assign(node.style, value);
    else if (key.startsWith("on") && typeof value === "function") {
      node.addEventListener(key.slice(2).toLowerCase(), value);
    } else node.setAttribute(key, value);
  }
  for (const child of children.flat()) {
    if (child == null || child === false) continue;
    node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
  }
  return node;
}

function header() {
  return el("header", { className: "ms-header" },
    el("a", { className: "ms-brand", href: SITE.origin, target: "_blank", rel: "noopener" },
      el("span", { className: "ms-brand-mark", text: "M" }),
      el("span", { className: "ms-brand-name", text: SITE.name }),
    ),
    el("a", { className: "ms-header-link", href: SITE.origin, target: "_blank", rel: "noopener",
      html: "All tools <span aria-hidden=\"true\">↗</span>" }),
  );
}

function crossSell(toolKey) {
  const items = TOOL_CATALOG.filter((t) => t.key !== toolKey);
  return el("footer", { className: "ms-footer" },
    el("p", { className: "ms-footer-title", text: "More free tools on MesmerTools" }),
    el("div", { className: "ms-grid" },
      ...items.map((t) =>
        el("a", { className: "ms-tool-card", href: siteUrl(t.path), target: "_blank", rel: "noopener" },
          el("span", { className: "ms-tool-emoji", text: t.emoji }),
          el("span", { className: "ms-tool-text" },
            el("span", { className: "ms-tool-name", text: t.name }),
            el("span", { className: "ms-tool-desc", text: t.desc }),
          ),
        ),
      ),
    ),
    el("p", { className: "ms-powered" },
      "Powered by ",
      el("a", { href: SITE.origin, target: "_blank", rel: "noopener", text: "mesmer.tools" }),
      " — runs on the public MesmerTools API.",
    ),
  );
}

/**
 * @param {object} opts
 * @param {string} opts.toolKey   key in ENDPOINTS / TOOL_CATALOG (also excluded from the strip)
 * @param {string} opts.emoji
 * @param {string} opts.title
 * @param {string} opts.subtitle
 * @param {string} [opts.intro]   optional longer paragraph under the subtitle
 * @returns {{form: HTMLFormElement, output: HTMLElement, setLoading: Function,
 *            showError: Function, showRateLimit: Function, clearStatus: Function,
 *            clearOutput: Function, limitNote: string}}
 */
export function createSpace(opts) {
  const { toolKey, emoji, title, subtitle, intro } = opts;
  const endpoint = ENDPOINTS[toolKey] || {};
  const limit = endpoint.freeLimitPerHour;
  const limitNote = limit
    ? `${limit} free requests/hour per user · higher limits on mesmer.tools`
    : `Free · backed by mesmer.tools`;

  const root = document.getElementById("app") || document.body;

  const status = el("div", { className: "ms-status", role: "status", "aria-live": "polite" });
  const form = el("form", { className: "ms-form" });
  const output = el("div", { className: "ms-output" });

  const hero = el("section", { className: "ms-hero" },
    el("div", { className: "ms-hero-emoji", text: emoji }),
    el("h1", { className: "ms-title", text: title }),
    el("p", { className: "ms-subtitle", text: subtitle }),
    intro ? el("p", { className: "ms-intro", text: intro }) : null,
    el("p", { className: "ms-limit-note", text: limitNote }),
  );

  const card = el("section", { className: "ms-card" }, form, status, output);
  const main = el("main", { className: "ms-main" }, hero, card);

  root.appendChild(header());
  root.appendChild(main);
  root.appendChild(crossSell(toolKey));

  function clearStatus() {
    status.replaceChildren();
  }

  function showRateLimit() {
    const full = siteUrl(endpoint.fullToolPath || "/");
    const msg = limit
      ? `You've used your ${limit} free requests this hour. Each visitor gets their own quota — `
      : `You've hit a temporary limit — `;
    clearStatus();
    status.appendChild(
      el("div", { className: "ms-banner ms-banner-limit" },
        el("strong", { text: "Free limit reached. " }),
        msg,
        el("a", { className: "ms-banner-cta", href: full, target: "_blank", rel: "noopener",
          text: "use the full tool on mesmer.tools →" }),
      ),
    );
  }

  /** Renders a RateLimitError as the cross-sell banner; anything else as an error. */
  function showError(err) {
    if (err instanceof RateLimitError || (err && err.isRateLimit)) {
      showRateLimit();
      return;
    }
    const text = typeof err === "string" ? err : (err && err.message) || "Something went wrong.";
    clearStatus();
    status.appendChild(el("div", { className: "ms-banner ms-banner-error", text }));
  }

  function setLoading(isLoading, label) {
    card.classList.toggle("is-loading", !!isLoading);
    if (isLoading) {
      clearStatus();
      status.appendChild(
        el("div", { className: "ms-banner ms-banner-loading" },
          el("span", { className: "ms-spinner", "aria-hidden": "true" }),
          el("span", { text: label || "Working…" }),
        ),
      );
    } else {
      clearStatus();
    }
  }

  function clearOutput() {
    output.replaceChildren();
  }

  return { form, output, status, setLoading, showError, showRateLimit, clearStatus, clearOutput, limitNote };
}

/** Standalone footer for non-tool spaces (e.g. the benchmark) that build their own body. */
export function mountChrome(toolKey) {
  const root = document.getElementById("app") || document.body;
  root.prepend(header());
  root.appendChild(crossSell(toolKey));
}