mesmertech commited on
Commit
27ec081
·
verified ·
1 Parent(s): 912f7dd

Deploy static space build

Browse files
Files changed (7) hide show
  1. README.md +55 -4
  2. app.js +213 -0
  3. index.html +32 -17
  4. shared/api-client.js +114 -0
  5. shared/config.js +84 -0
  6. shared/ui.css +250 -0
  7. shared/ui.js +166 -0
README.md CHANGED
@@ -1,10 +1,61 @@
1
  ---
2
- title: Mesmer Screenshot
3
- emoji: 📚
4
  colorFrom: yellow
5
- colorTo: blue
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Free Website Screenshot API — Capture Any URL as an Image
3
+ emoji: 📸
4
  colorFrom: yellow
5
+ colorTo: red
6
  sdk: static
7
  pinned: false
8
+ short_description: Capture a screenshot of any website URL as an image. Free
9
  ---
10
 
11
+ # Free Website Screenshot API Capture Any URL as an Image
12
+
13
+ Paste any website URL, pick a size, and get back a JPG image. This is a free,
14
+ no-signup demo of the **[MesmerTools Screenshot API](https://mesmer.tools/api-tools/screenshot)**
15
+ that runs entirely in your browser — handy for previews, thumbnails, link cards,
16
+ and quick visual checks of any public page.
17
+
18
+ ## What it does
19
+
20
+ - **Screenshot any URL online** — enter `https://example.com` and capture it as an image.
21
+ - **URL to image** — the API returns a hosted JPG on `cdn.mesmer.tools` you can open or download.
22
+ - **Custom size** — set width (320–1920px, default 1280) and height (240–1080px, default 720),
23
+ or use the Desktop / Wide / Mobile presets.
24
+ - **Cached + fast** — repeat captures of the same page are served from cache.
25
+
26
+ ## How it works
27
+
28
+ This Space calls the public `GET /api/v1/screenshot` endpoint directly from your
29
+ browser, so every visitor gets their own free quota (no shared key, no proxy):
30
+
31
+ ```
32
+ GET https://mesmer.tools/api/v1/screenshot?url=https://github.com&width=1280&height=720
33
+ → { "url": "https://cdn.mesmer.tools/screenshots/api/….jpg", "width": 1280, "height": 720, "cached": false }
34
+ ```
35
+
36
+ | Param | Required | Range / default |
37
+ | ------ | -------- | ---------------------------- |
38
+ | `url` | yes | a website URL (https://…) |
39
+ | `width`| no | 320–1920, default 1280 |
40
+ | `height`| no | 240–1080, default 720 |
41
+
42
+ ## Free limits
43
+
44
+ The free demo allows **10 screenshots per hour per visitor**. Need higher limits,
45
+ batch capture, or full-page screenshots for production? Use the full tool:
46
+ **[mesmer.tools/api-tools/screenshot](https://mesmer.tools/api-tools/screenshot)**.
47
+
48
+ ## More free tools on MesmerTools
49
+
50
+ - [Site Logo / Favicon API](https://mesmer.tools/api-tools/site-logo) — fetch any website's logo.
51
+ - [Text to Speech](https://mesmer.tools/api-tools/tts) — natural AI voices, free.
52
+ - [AI Logo Maker](https://mesmer.tools/free-tools/ai-logo-maker) — generate a brand logo from a prompt.
53
+ - [JSON Formatter](https://mesmer.tools/free-tools/json-formatter) — format and validate JSON.
54
+ - [Token Price Calculator](https://mesmer.tools/free-tools/token-price-calculator) — estimate LLM API costs.
55
+
56
+ Browse everything at **[mesmer.tools](https://mesmer.tools)** — free developer & AI tools.
57
+
58
+ ---
59
+
60
+ **Keywords:** free website screenshot api, screenshot any url online, url to image,
61
+ website screenshot generator, capture website as image, webpage screenshot api.
app.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createSpace, el } from "./shared/ui.js";
2
+ import { callRest } from "./shared/api-client.js";
3
+ import { ENDPOINTS, siteUrl } from "./shared/config.js";
4
+
5
+ const TOOL_KEY = "screenshot";
6
+ const API_PATH = ENDPOINTS[TOOL_KEY].apiPath; // "/api/v1/screenshot"
7
+ const FULL_TOOL_URL = siteUrl(ENDPOINTS[TOOL_KEY].fullToolPath); // mesmer.tools/api-tools/screenshot
8
+
9
+ // Allowed dimension ranges (validated client-side before the API call).
10
+ const WIDTH = { min: 320, max: 1920, default: 1280 };
11
+ const HEIGHT = { min: 240, max: 1080, default: 720 };
12
+
13
+ const space = createSpace({
14
+ toolKey: TOOL_KEY,
15
+ emoji: "📸",
16
+ title: "Free Website Screenshot API",
17
+ subtitle: "Capture a screenshot of any website URL as an image. Paste a link, pick a size, get a JPG — free, no signup.",
18
+ intro: "Turn any URL into an image right from your browser. Great for previews, thumbnails, and link cards.",
19
+ });
20
+
21
+ /* --- Form ----------------------------------------------------------------- */
22
+
23
+ const urlInput = el("input", {
24
+ className: "ms-input",
25
+ type: "url",
26
+ id: "url",
27
+ name: "url",
28
+ required: "",
29
+ placeholder: "https://example.com",
30
+ autocomplete: "off",
31
+ autocapitalize: "off",
32
+ spellcheck: "false",
33
+ });
34
+
35
+ const widthInput = el("input", {
36
+ className: "ms-input",
37
+ type: "number",
38
+ id: "width",
39
+ name: "width",
40
+ inputmode: "numeric",
41
+ min: String(WIDTH.min),
42
+ max: String(WIDTH.max),
43
+ step: "1",
44
+ placeholder: String(WIDTH.default),
45
+ });
46
+
47
+ const heightInput = el("input", {
48
+ className: "ms-input",
49
+ type: "number",
50
+ id: "height",
51
+ name: "height",
52
+ inputmode: "numeric",
53
+ min: String(HEIGHT.min),
54
+ max: String(HEIGHT.max),
55
+ step: "1",
56
+ placeholder: String(HEIGHT.default),
57
+ });
58
+
59
+ const widthHint = el("span", { className: "ms-hint", text: `Optional · ${WIDTH.min}–${WIDTH.max}, default ${WIDTH.default}` });
60
+ const heightHint = el("span", { className: "ms-hint", text: `Optional · ${HEIGHT.min}–${HEIGHT.max}, default ${HEIGHT.default}` });
61
+
62
+ function preset(label, w, h) {
63
+ return el("button", {
64
+ className: "ms-btn-ghost",
65
+ type: "button",
66
+ onClick: () => {
67
+ widthInput.value = String(w);
68
+ heightInput.value = String(h);
69
+ validate(widthInput, widthHint, WIDTH);
70
+ validate(heightInput, heightHint, HEIGHT);
71
+ },
72
+ }, label);
73
+ }
74
+
75
+ const submitBtn = el("button", { className: "ms-btn", type: "submit" }, "Capture screenshot");
76
+
77
+ space.form.append(
78
+ el("div", { className: "ms-field" },
79
+ el("label", { for: "url", text: "Website URL" }),
80
+ urlInput,
81
+ el("span", { className: "ms-hint", text: "Any public page, e.g. https://github.com" }),
82
+ ),
83
+ el("div", { className: "ms-row" },
84
+ el("div", { className: "ms-field" },
85
+ el("label", { for: "width", text: "Width (px)" }),
86
+ widthInput,
87
+ widthHint,
88
+ ),
89
+ el("div", { className: "ms-field" },
90
+ el("label", { for: "height", text: "Height (px)" }),
91
+ heightInput,
92
+ heightHint,
93
+ ),
94
+ ),
95
+ el("div", { className: "ms-field" },
96
+ el("label", { text: "Size presets" }),
97
+ el("div", { className: "ms-presets" },
98
+ preset("Desktop 1280×720", 1280, 720),
99
+ preset("Wide 1920×1080", 1920, 1080),
100
+ preset("Mobile 390×844", 390, 844),
101
+ ),
102
+ ),
103
+ submitBtn,
104
+ el("p", { className: "ms-api-note" },
105
+ "Free demo: ",
106
+ String(ENDPOINTS[TOOL_KEY].freeLimitPerHour),
107
+ " screenshots/hour per visitor. Need more? ",
108
+ el("a", { href: FULL_TOOL_URL, target: "_blank", rel: "noopener", text: "Higher limits on mesmer.tools →" }),
109
+ ),
110
+ );
111
+
112
+ // Live-validate dimension fields as the user edits them.
113
+ widthInput.addEventListener("input", () => validate(widthInput, widthHint, WIDTH));
114
+ heightInput.addEventListener("input", () => validate(heightInput, heightHint, HEIGHT));
115
+
116
+ /* --- Validation ----------------------------------------------------------- */
117
+
118
+ /**
119
+ * Validates a dimension input. Empty means "use the default".
120
+ * Returns { value, ok }; on failure marks the hint red and value is null.
121
+ */
122
+ function validate(input, hint, range) {
123
+ const raw = input.value.trim();
124
+ if (raw === "") {
125
+ resetHint(hint, range, "ok");
126
+ return { value: range.default, ok: true };
127
+ }
128
+ const n = Number(raw);
129
+ if (!Number.isInteger(n)) {
130
+ setHint(hint, "Enter a whole number of pixels.", true);
131
+ return { value: null, ok: false };
132
+ }
133
+ if (n < range.min || n > range.max) {
134
+ setHint(hint, `Must be between ${range.min} and ${range.max}px.`, true);
135
+ return { value: null, ok: false };
136
+ }
137
+ resetHint(hint, range, "ok");
138
+ return { value: n, ok: true };
139
+ }
140
+
141
+ function resetHint(hint, range, _state) {
142
+ const min = range === WIDTH ? WIDTH.min : HEIGHT.min;
143
+ const max = range === WIDTH ? WIDTH.max : HEIGHT.max;
144
+ setHint(hint, `Optional · ${min}–${max}, default ${range.default}`, false);
145
+ }
146
+
147
+ function setHint(hint, text, isError) {
148
+ hint.textContent = text;
149
+ hint.style.color = isError ? "#fca5a5" : "";
150
+ }
151
+
152
+ /* --- Submit --------------------------------------------------------------- */
153
+
154
+ space.form.addEventListener("submit", async (e) => {
155
+ e.preventDefault();
156
+
157
+ const url = urlInput.value.trim();
158
+ if (!url) {
159
+ urlInput.focus();
160
+ return;
161
+ }
162
+
163
+ const w = validate(widthInput, widthHint, WIDTH);
164
+ const h = validate(heightInput, heightHint, HEIGHT);
165
+ if (!w.ok || !h.ok) {
166
+ (!w.ok ? widthInput : heightInput).focus();
167
+ return;
168
+ }
169
+
170
+ space.clearOutput();
171
+ space.setLoading(true, "Capturing screenshot…");
172
+ submitBtn.disabled = true;
173
+
174
+ try {
175
+ const data = await callRest(API_PATH, {
176
+ params: { url, width: w.value, height: h.value },
177
+ });
178
+ space.clearStatus();
179
+ renderResult(data);
180
+ } catch (err) {
181
+ space.clearOutput();
182
+ space.showError(err);
183
+ } finally {
184
+ submitBtn.disabled = false;
185
+ }
186
+ });
187
+
188
+ /* --- Result --------------------------------------------------------------- */
189
+
190
+ function renderResult(data) {
191
+ // Success shape: { url, width, height, cached }
192
+ const imgUrl = data.url;
193
+ const width = data.width;
194
+ const height = data.height;
195
+
196
+ const filename = `screenshot-${width}x${height}.jpg`;
197
+
198
+ space.output.append(
199
+ el("div", { className: "ms-result-frame" },
200
+ el("img", {
201
+ src: imgUrl,
202
+ alt: `Screenshot of ${urlInput.value.trim()} at ${width}×${height}`,
203
+ loading: "lazy",
204
+ }),
205
+ ),
206
+ el("div", { className: "ms-result-actions" },
207
+ el("a", { className: "ms-btn-ghost", href: imgUrl, target: "_blank", rel: "noopener", text: "Open full size ↗" }),
208
+ el("a", { className: "ms-btn-ghost", href: imgUrl, download: filename, text: "Download" }),
209
+ ),
210
+ el("p", { className: "ms-meta",
211
+ text: `${width}×${height} · ${data.cached ? "served from cache" : "freshly captured"}` }),
212
+ );
213
+ }
index.html CHANGED
@@ -1,19 +1,34 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Free Website Screenshot API — Capture Any URL as an Image</title>
7
+ <meta name="description" content="Capture a screenshot of any website URL as an image, free and with no signup. Paste a URL, pick a size, and get a JPG. Free website screenshot API for turning any URL to image online." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="./shared/ui.css" />
11
+ <style>
12
+ /* Tool-specific touches layered on top of the shared theme. */
13
+ .ms-presets { display: flex; gap: 8px; flex-wrap: wrap; }
14
+ .ms-presets .ms-btn-ghost { padding: 8px 14px; font-size: 13px; font-weight: 600; }
15
+ .ms-api-note {
16
+ margin-top: 14px;
17
+ font-size: 12.5px;
18
+ color: var(--text-muted);
19
+ }
20
+ .ms-api-note code {
21
+ padding: 1px 5px;
22
+ border: 1px solid var(--border);
23
+ border-radius: 5px;
24
+ background: var(--bg-elevated);
25
+ color: var(--text-secondary);
26
+ font-size: 12px;
27
+ }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div id="app"></div>
32
+ <script type="module" src="./app.js"></script>
33
+ </body>
34
  </html>
shared/api-client.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared API client for all MesmerTools HuggingFace Spaces.
3
+ *
4
+ * Spaces are static pages that call the mesmer.tools API directly from the
5
+ * visitor's browser. That is deliberate: per-IP rate limits then apply per
6
+ * visitor instead of being shared through one Space backend. CORS on
7
+ * /api/v1/* is already `*`, so cross-origin fetches work with no proxy.
8
+ *
9
+ * Every helper throws `RateLimitError` on a 429 so the UI can surface the
10
+ * "use the full tool for higher limits" cross-sell, and `ApiError` otherwise.
11
+ */
12
+
13
+ import { SITE } from "./config.js";
14
+
15
+ export class RateLimitError extends Error {
16
+ constructor(message) {
17
+ super(message || "You've hit the free hourly limit.");
18
+ this.name = "RateLimitError";
19
+ this.isRateLimit = true;
20
+ }
21
+ }
22
+
23
+ export class ApiError extends Error {
24
+ constructor(message, status = 0) {
25
+ super(message || "Something went wrong.");
26
+ this.name = "ApiError";
27
+ this.status = status;
28
+ }
29
+ }
30
+
31
+ const DEFAULT_TIMEOUT = 60_000;
32
+
33
+ async function doFetch(url, init, timeout) {
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => ctrl.abort(), timeout ?? DEFAULT_TIMEOUT);
36
+ try {
37
+ return await fetch(url, { ...init, signal: ctrl.signal });
38
+ } catch (err) {
39
+ if (err && err.name === "AbortError") {
40
+ throw new ApiError(
41
+ "The request timed out. The free demo can be slow under load — try the full tool on mesmer.tools.",
42
+ 408,
43
+ );
44
+ }
45
+ throw new ApiError("Network error. Check your connection and try again.", 0);
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+
51
+ async function readJson(res) {
52
+ try {
53
+ return await res.json();
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * REST call. GET with `params`, or POST with a JSON `body`.
61
+ * @returns parsed JSON response
62
+ */
63
+ export async function callRest(apiPath, { method = "GET", params, body, timeout } = {}) {
64
+ let url = SITE.origin + apiPath;
65
+ const init = { method, headers: {} };
66
+ if (params) {
67
+ const qs = new URLSearchParams(params).toString();
68
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
69
+ }
70
+ if (body !== undefined) {
71
+ init.headers["Content-Type"] = "application/json";
72
+ init.body = JSON.stringify(body);
73
+ }
74
+ const res = await doFetch(url, init, timeout);
75
+ const data = await readJson(res);
76
+ if (res.status === 429) throw new RateLimitError(data && data.error);
77
+ if (!res.ok) throw new ApiError((data && data.error) || `Request failed (${res.status}).`, res.status);
78
+ return data;
79
+ }
80
+
81
+ /**
82
+ * tRPC v11 mutation (superjson transformer). Used by the logo space.
83
+ * Input is wrapped as `{ json: input }`; the unwrapped value is returned.
84
+ */
85
+ export async function callTrpcMutation(trpcPath, input, { timeout } = {}) {
86
+ const res = await doFetch(
87
+ SITE.origin + trpcPath,
88
+ {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({ json: input }),
92
+ },
93
+ timeout,
94
+ );
95
+ const data = await readJson(res);
96
+ if (res.status === 429) throw new RateLimitError(extractTrpcError(data));
97
+ if (!res.ok) throw new ApiError(extractTrpcError(data) || `Request failed (${res.status}).`, res.status);
98
+ // superjson success envelope: { result: { data: { json: <value> } } }
99
+ const out = data && data.result && data.result.data;
100
+ return out && typeof out === "object" && "json" in out ? out.json : out;
101
+ }
102
+
103
+ function extractTrpcError(data) {
104
+ if (!data || !data.error) return null;
105
+ // superjson error envelope: { error: { json: { message, data: {...} } } }
106
+ return (data.error.json && data.error.json.message) || data.error.message || null;
107
+ }
108
+
109
+ /** Fetch a generated JSON artifact (benchmark, voices) from mesmer.tools. */
110
+ export async function fetchData(url, { timeout } = {}) {
111
+ const res = await doFetch(url, { method: "GET" }, timeout ?? 20_000);
112
+ if (!res.ok) throw new ApiError(`Could not load data (${res.status}).`, res.status);
113
+ return readJson(res);
114
+ }
shared/config.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Single source of shared config for every MesmerTools HuggingFace Space.
3
+ * Authored once here and imported by all spaces so nothing is duplicated.
4
+ *
5
+ * Data that changes often (TTS voices, benchmark runs) is NOT hardcoded — it is
6
+ * fetched at runtime from generated JSON on mesmer.tools (see DATA) so the spaces
7
+ * always reflect the canonical constants in the main repo.
8
+ */
9
+
10
+ export const SITE = {
11
+ name: "MesmerTools",
12
+ origin: "https://mesmer.tools",
13
+ cdn: "https://cdn.mesmer.tools",
14
+ tagline: "Free developer & AI tools.",
15
+ };
16
+
17
+ /**
18
+ * Per-endpoint contract.
19
+ * `freeLimitPerHour` is the per-IP free cap the API enforces (null = no hard
20
+ * hourly cap). Because each space calls the API directly from the visitor's
21
+ * browser, this limit is per-user — every HuggingFace visitor gets their own
22
+ * bucket. On a 429 the space points users at `fullToolPath` for higher limits.
23
+ */
24
+ export const ENDPOINTS = {
25
+ screenshot: {
26
+ apiPath: "/api/v1/screenshot",
27
+ method: "GET",
28
+ freeLimitPerHour: 10,
29
+ fullToolPath: "/api-tools/screenshot",
30
+ },
31
+ siteLogo: {
32
+ apiPath: "/api/v1/site-logo",
33
+ method: "GET",
34
+ freeLimitPerHour: null, // cached server-side; no hard hourly cap
35
+ fullToolPath: "/api-tools/site-logo",
36
+ },
37
+ tts: {
38
+ apiPath: "/api/v1/tts",
39
+ method: "POST",
40
+ freeLimitPerHour: 20,
41
+ fullToolPath: "/api-tools/tts",
42
+ },
43
+ logo: {
44
+ trpcPath: "/api/trpc/logo.generate",
45
+ method: "POST",
46
+ freeLimitPerHour: 10,
47
+ fullToolPath: "/free-tools/ai-logo-maker",
48
+ },
49
+ benchmark: {
50
+ fullToolPath: "/benchmarks/ai-video-generation",
51
+ },
52
+ };
53
+
54
+ /**
55
+ * Live data endpoints. These serve JSON straight from the canonical constants
56
+ * in the main repo (no generated snapshot, no build step) so the spaces always
57
+ * reflect the current voices / benchmark dict. Edge-cached for an hour.
58
+ */
59
+ export const DATA = {
60
+ ttsVoices: `${SITE.origin}/api/v1/tts/voices`,
61
+ videoBenchmark: `${SITE.origin}/api/v1/benchmarks/ai-video-generation`,
62
+ };
63
+
64
+ /**
65
+ * Cross-sell catalog shown in the footer strip of every space. These are the
66
+ * SEO backlinks to mesmer.tools. `key` matches ENDPOINTS keys so a space can
67
+ * exclude itself from its own strip.
68
+ */
69
+ export const TOOL_CATALOG = [
70
+ { key: "logo", emoji: "🎨", name: "AI Logo Maker", desc: "Generate a brand logo from a prompt.", path: "/free-tools/ai-logo-maker" },
71
+ { key: "tts", emoji: "🔊", name: "Text to Speech", desc: "Natural AI voices, free.", path: "/api-tools/tts" },
72
+ { key: "screenshot", emoji: "📸", name: "Screenshot API", desc: "Capture any URL as an image.", path: "/api-tools/screenshot" },
73
+ { key: "siteLogo", emoji: "🏷️", name: "Site Logo / Favicon", desc: "Fetch any website's logo.", path: "/api-tools/site-logo" },
74
+ { key: "music", emoji: "🎵", name: "AI Music Maker", desc: "Generate short music clips.", path: "/free-tools/ai-music-maker" },
75
+ { key: "tokens", emoji: "🧮", name: "Token Price Calculator", desc: "Estimate LLM API costs.", path: "/free-tools/token-price-calculator" },
76
+ { key: "json", emoji: "{ }", name: "JSON Formatter", desc: "Format & validate JSON.", path: "/free-tools/json-formatter" },
77
+ { key: "synthid", emoji: "🔍", name: "Detect SynthID", desc: "Check images for AI watermarks.", path: "/free-tools/detect-synthid" },
78
+ { key: "benchmark", emoji: "🏁", name: "AI Video Benchmark", desc: "Which LLM codes the best video?", path: "/benchmarks/ai-video-generation" },
79
+ ];
80
+
81
+ /** Absolute mesmer.tools URL for a path. */
82
+ export function siteUrl(path) {
83
+ return `${SITE.origin}${path}`;
84
+ }
shared/ui.css ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* MesmerTools HuggingFace Spaces — shared dark theme.
2
+ Tokens mirror DESIGN.md so the spaces feel like the main site. */
3
+
4
+ :root {
5
+ --bg: #0d0d0d;
6
+ --bg-elevated: #161616;
7
+ --bg-card: #1a1a1a;
8
+ --bg-card-hover: #222222;
9
+ --border: #2a2a2a;
10
+ --border-hover: #3a3a3a;
11
+ --amber: #f59e0b;
12
+ --amber-dim: rgba(245, 158, 11, 0.15);
13
+ --amber-glow: rgba(245, 158, 11, 0.35);
14
+ --sienna: #c2410c;
15
+ --text-primary: #eeeeee;
16
+ --text-secondary: #999999;
17
+ --text-muted: #666666;
18
+ --grad: linear-gradient(135deg, var(--amber), var(--sienna));
19
+ --radius: 10px;
20
+ --maxw: 880px;
21
+ }
22
+
23
+ * { box-sizing: border-box; }
24
+
25
+ html { -webkit-text-size-adjust: 100%; }
26
+
27
+ body {
28
+ margin: 0;
29
+ background: var(--bg);
30
+ color: var(--text-primary);
31
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
32
+ -webkit-font-smoothing: antialiased;
33
+ line-height: 1.5;
34
+ }
35
+
36
+ a { color: var(--amber); text-decoration: none; }
37
+ a:hover { text-decoration: underline; }
38
+
39
+ /* --- Header --------------------------------------------------------- */
40
+ .ms-header {
41
+ position: sticky;
42
+ top: 0;
43
+ z-index: 10;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ gap: 16px;
48
+ height: 56px;
49
+ padding: 0 20px;
50
+ background: rgba(13, 13, 13, 0.85);
51
+ backdrop-filter: blur(10px);
52
+ border-bottom: 1px solid var(--border);
53
+ }
54
+ .ms-brand { display: flex; align-items: center; gap: 9px; color: var(--text-primary); }
55
+ .ms-brand:hover { text-decoration: none; }
56
+ .ms-brand-mark {
57
+ display: grid;
58
+ place-items: center;
59
+ width: 26px; height: 26px;
60
+ border-radius: 7px;
61
+ background: var(--grad);
62
+ color: var(--bg);
63
+ font-weight: 800;
64
+ font-size: 15px;
65
+ }
66
+ .ms-brand-name { font-weight: 700; letter-spacing: -0.3px; }
67
+ .ms-header-link { color: var(--text-secondary); font-size: 13px; font-weight: 500; }
68
+ .ms-header-link:hover { color: var(--amber); text-decoration: none; }
69
+
70
+ /* --- Layout --------------------------------------------------------- */
71
+ .ms-main {
72
+ max-width: var(--maxw);
73
+ margin: 0 auto;
74
+ padding: 40px 20px 8px;
75
+ }
76
+
77
+ .ms-hero { text-align: center; margin-bottom: 28px; }
78
+ .ms-hero-emoji { font-size: 40px; line-height: 1; margin-bottom: 12px; }
79
+ .ms-title {
80
+ margin: 0 0 10px;
81
+ font-size: clamp(26px, 5vw, 38px);
82
+ font-weight: 800;
83
+ letter-spacing: -0.8px;
84
+ background: var(--grad);
85
+ -webkit-background-clip: text;
86
+ background-clip: text;
87
+ color: transparent;
88
+ }
89
+ .ms-subtitle { margin: 0 auto; max-width: 620px; color: var(--text-secondary); font-size: 16px; }
90
+ .ms-intro { margin: 12px auto 0; max-width: 620px; color: var(--text-muted); font-size: 14px; }
91
+ .ms-limit-note {
92
+ display: inline-block;
93
+ margin-top: 16px;
94
+ padding: 5px 12px;
95
+ border: 1px solid var(--border);
96
+ border-radius: 20px;
97
+ background: var(--bg-elevated);
98
+ color: var(--text-muted);
99
+ font-size: 12px;
100
+ }
101
+
102
+ /* --- Card ----------------------------------------------------------- */
103
+ .ms-card {
104
+ background: var(--bg-card);
105
+ border: 1px solid var(--border);
106
+ border-radius: 14px;
107
+ padding: 24px;
108
+ }
109
+
110
+ /* --- Forms (generic, reusable by every space) ----------------------- */
111
+ .ms-form { display: flex; flex-direction: column; gap: 16px; }
112
+ .ms-field { display: flex; flex-direction: column; gap: 6px; }
113
+ .ms-field label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
114
+ .ms-field .ms-hint { font-size: 12px; color: var(--text-muted); }
115
+ .ms-row { display: flex; gap: 12px; flex-wrap: wrap; }
116
+ .ms-row > .ms-field { flex: 1 1 160px; }
117
+
118
+ .ms-input, .ms-textarea, .ms-select {
119
+ width: 100%;
120
+ padding: 11px 13px;
121
+ background: var(--bg-elevated);
122
+ border: 1px solid var(--border);
123
+ border-radius: 8px;
124
+ color: var(--text-primary);
125
+ font-family: inherit;
126
+ font-size: 14px;
127
+ transition: border-color 0.15s, box-shadow 0.15s;
128
+ }
129
+ .ms-textarea { resize: vertical; min-height: 110px; }
130
+ .ms-input:focus, .ms-textarea:focus, .ms-select:focus {
131
+ outline: none;
132
+ border-color: var(--amber);
133
+ box-shadow: 0 0 0 3px var(--amber-dim);
134
+ }
135
+ .ms-input::placeholder, .ms-textarea::placeholder { color: var(--text-muted); }
136
+ .ms-select { appearance: none; cursor: pointer; }
137
+
138
+ /* --- Buttons -------------------------------------------------------- */
139
+ .ms-btn {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ gap: 8px;
144
+ padding: 12px 20px;
145
+ border: none;
146
+ border-radius: 8px;
147
+ background: var(--grad);
148
+ color: #fff;
149
+ font-family: inherit;
150
+ font-size: 14px;
151
+ font-weight: 700;
152
+ cursor: pointer;
153
+ transition: transform 0.15s, opacity 0.15s, filter 0.15s;
154
+ }
155
+ .ms-btn:hover { filter: brightness(1.08); }
156
+ .ms-btn:active { transform: translateY(1px); }
157
+ .ms-btn:disabled { opacity: 0.55; cursor: not-allowed; filter: none; }
158
+ .ms-btn-ghost {
159
+ background: var(--bg-elevated);
160
+ color: var(--text-primary);
161
+ border: 1px solid var(--border);
162
+ }
163
+ .ms-btn-ghost:hover { border-color: var(--border-hover); filter: none; }
164
+
165
+ .ms-char-count { font-size: 12px; color: var(--text-muted); text-align: right; }
166
+
167
+ /* --- Status banners ------------------------------------------------- */
168
+ .ms-status:empty { display: none; }
169
+ .ms-status { margin-top: 16px; }
170
+ .ms-banner {
171
+ padding: 12px 14px;
172
+ border-radius: 8px;
173
+ font-size: 14px;
174
+ border: 1px solid var(--border);
175
+ }
176
+ .ms-banner-error { background: rgba(220, 38, 38, 0.1); border-color: rgba(220, 38, 38, 0.4); color: #fca5a5; }
177
+ .ms-banner-limit { background: var(--amber-dim); border-color: var(--amber-glow); color: #fcd9a0; }
178
+ .ms-banner-limit .ms-banner-cta { display: inline-block; margin-top: 4px; font-weight: 700; }
179
+ .ms-banner-loading { display: flex; align-items: center; gap: 10px; background: var(--bg-elevated); color: var(--text-secondary); }
180
+ .ms-spinner {
181
+ width: 16px; height: 16px;
182
+ border: 2px solid var(--border-hover);
183
+ border-top-color: var(--amber);
184
+ border-radius: 50%;
185
+ animation: ms-spin 0.7s linear infinite;
186
+ }
187
+ @keyframes ms-spin { to { transform: rotate(360deg); } }
188
+
189
+ /* --- Output --------------------------------------------------------- */
190
+ .ms-output:empty { display: none; }
191
+ .ms-output { margin-top: 18px; }
192
+ .ms-output img, .ms-output video { max-width: 100%; border-radius: 10px; display: block; }
193
+ .ms-result-frame {
194
+ border: 1px solid var(--border);
195
+ border-radius: 10px;
196
+ overflow: hidden;
197
+ background: var(--bg-elevated);
198
+ }
199
+ .ms-result-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
200
+ .ms-meta { margin-top: 10px; font-size: 12px; color: var(--text-muted); }
201
+ .ms-audio { width: 100%; margin-top: 6px; }
202
+
203
+ /* --- Footer cross-sell ---------------------------------------------- */
204
+ .ms-footer {
205
+ max-width: var(--maxw);
206
+ margin: 48px auto 0;
207
+ padding: 28px 20px 56px;
208
+ border-top: 1px solid var(--border);
209
+ }
210
+ .ms-footer-title {
211
+ margin: 0 0 16px;
212
+ font-size: 13px;
213
+ font-weight: 600;
214
+ text-transform: uppercase;
215
+ letter-spacing: 0.6px;
216
+ color: var(--text-muted);
217
+ }
218
+ .ms-grid {
219
+ display: grid;
220
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
221
+ gap: 12px;
222
+ }
223
+ .ms-tool-card {
224
+ display: flex;
225
+ align-items: flex-start;
226
+ gap: 11px;
227
+ padding: 14px;
228
+ border: 1px solid var(--border);
229
+ border-radius: var(--radius);
230
+ background: var(--bg-card);
231
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.2s, background 0.2s;
232
+ }
233
+ .ms-tool-card:hover {
234
+ transform: translateY(-3px);
235
+ background: var(--bg-card-hover);
236
+ border-color: var(--amber-glow);
237
+ text-decoration: none;
238
+ }
239
+ .ms-tool-emoji { font-size: 20px; line-height: 1.3; }
240
+ .ms-tool-text { display: flex; flex-direction: column; gap: 2px; }
241
+ .ms-tool-name { color: var(--text-primary); font-weight: 600; font-size: 14px; }
242
+ .ms-tool-desc { color: var(--text-secondary); font-size: 12.5px; }
243
+ .ms-powered { margin: 22px 0 0; color: var(--text-muted); font-size: 12.5px; }
244
+
245
+ /* --- Responsive ----------------------------------------------------- */
246
+ @media (max-width: 560px) {
247
+ .ms-main { padding-top: 28px; }
248
+ .ms-card { padding: 18px; }
249
+ .ms-grid { grid-template-columns: 1fr; }
250
+ }
shared/ui.js ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared page chrome for all MesmerTools HuggingFace Spaces.
3
+ *
4
+ * `createSpace()` builds the header, hero, a card with a <form> + output slot,
5
+ * a status area (errors + the rate-limit cross-sell banner), and the footer
6
+ * "more free tools" strip (the SEO backlinks). A tool space only has to fill
7
+ * the returned `form` and `output` elements and wire its submit handler.
8
+ */
9
+
10
+ import { SITE, ENDPOINTS, TOOL_CATALOG, siteUrl } from "./config.js";
11
+ import { RateLimitError } from "./api-client.js";
12
+
13
+ /** Tiny hyperscript helper. `props` supports className, text, html, on*, attrs, dataset, style. */
14
+ export function el(tag, props = {}, ...children) {
15
+ const node = document.createElement(tag);
16
+ for (const [key, value] of Object.entries(props || {})) {
17
+ if (value == null) continue;
18
+ if (key === "className") node.className = value;
19
+ else if (key === "text") node.textContent = value;
20
+ else if (key === "html") node.innerHTML = value;
21
+ else if (key === "dataset") Object.assign(node.dataset, value);
22
+ else if (key === "style" && typeof value === "object") Object.assign(node.style, value);
23
+ else if (key.startsWith("on") && typeof value === "function") {
24
+ node.addEventListener(key.slice(2).toLowerCase(), value);
25
+ } else node.setAttribute(key, value);
26
+ }
27
+ for (const child of children.flat()) {
28
+ if (child == null || child === false) continue;
29
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
30
+ }
31
+ return node;
32
+ }
33
+
34
+ function header() {
35
+ return el("header", { className: "ms-header" },
36
+ el("a", { className: "ms-brand", href: SITE.origin, target: "_blank", rel: "noopener" },
37
+ el("span", { className: "ms-brand-mark", text: "M" }),
38
+ el("span", { className: "ms-brand-name", text: SITE.name }),
39
+ ),
40
+ el("a", { className: "ms-header-link", href: SITE.origin, target: "_blank", rel: "noopener",
41
+ html: "All tools <span aria-hidden=\"true\">↗</span>" }),
42
+ );
43
+ }
44
+
45
+ function crossSell(toolKey) {
46
+ const items = TOOL_CATALOG.filter((t) => t.key !== toolKey);
47
+ return el("footer", { className: "ms-footer" },
48
+ el("p", { className: "ms-footer-title", text: "More free tools on MesmerTools" }),
49
+ el("div", { className: "ms-grid" },
50
+ ...items.map((t) =>
51
+ el("a", { className: "ms-tool-card", href: siteUrl(t.path), target: "_blank", rel: "noopener" },
52
+ el("span", { className: "ms-tool-emoji", text: t.emoji }),
53
+ el("span", { className: "ms-tool-text" },
54
+ el("span", { className: "ms-tool-name", text: t.name }),
55
+ el("span", { className: "ms-tool-desc", text: t.desc }),
56
+ ),
57
+ ),
58
+ ),
59
+ ),
60
+ el("p", { className: "ms-powered" },
61
+ "Powered by ",
62
+ el("a", { href: SITE.origin, target: "_blank", rel: "noopener", text: "mesmer.tools" }),
63
+ " — runs on the public MesmerTools API.",
64
+ ),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * @param {object} opts
70
+ * @param {string} opts.toolKey key in ENDPOINTS / TOOL_CATALOG (also excluded from the strip)
71
+ * @param {string} opts.emoji
72
+ * @param {string} opts.title
73
+ * @param {string} opts.subtitle
74
+ * @param {string} [opts.intro] optional longer paragraph under the subtitle
75
+ * @returns {{form: HTMLFormElement, output: HTMLElement, setLoading: Function,
76
+ * showError: Function, showRateLimit: Function, clearStatus: Function,
77
+ * clearOutput: Function, limitNote: string}}
78
+ */
79
+ export function createSpace(opts) {
80
+ const { toolKey, emoji, title, subtitle, intro } = opts;
81
+ const endpoint = ENDPOINTS[toolKey] || {};
82
+ const limit = endpoint.freeLimitPerHour;
83
+ const limitNote = limit
84
+ ? `${limit} free requests/hour per user · higher limits on mesmer.tools`
85
+ : `Free · backed by mesmer.tools`;
86
+
87
+ const root = document.getElementById("app") || document.body;
88
+
89
+ const status = el("div", { className: "ms-status", role: "status", "aria-live": "polite" });
90
+ const form = el("form", { className: "ms-form" });
91
+ const output = el("div", { className: "ms-output" });
92
+
93
+ const hero = el("section", { className: "ms-hero" },
94
+ el("div", { className: "ms-hero-emoji", text: emoji }),
95
+ el("h1", { className: "ms-title", text: title }),
96
+ el("p", { className: "ms-subtitle", text: subtitle }),
97
+ intro ? el("p", { className: "ms-intro", text: intro }) : null,
98
+ el("p", { className: "ms-limit-note", text: limitNote }),
99
+ );
100
+
101
+ const card = el("section", { className: "ms-card" }, form, status, output);
102
+ const main = el("main", { className: "ms-main" }, hero, card);
103
+
104
+ root.appendChild(header());
105
+ root.appendChild(main);
106
+ root.appendChild(crossSell(toolKey));
107
+
108
+ function clearStatus() {
109
+ status.replaceChildren();
110
+ }
111
+
112
+ function showRateLimit() {
113
+ const full = siteUrl(endpoint.fullToolPath || "/");
114
+ const msg = limit
115
+ ? `You've used your ${limit} free requests this hour. Each visitor gets their own quota — `
116
+ : `You've hit a temporary limit — `;
117
+ clearStatus();
118
+ status.appendChild(
119
+ el("div", { className: "ms-banner ms-banner-limit" },
120
+ el("strong", { text: "Free limit reached. " }),
121
+ msg,
122
+ el("a", { className: "ms-banner-cta", href: full, target: "_blank", rel: "noopener",
123
+ text: "use the full tool on mesmer.tools →" }),
124
+ ),
125
+ );
126
+ }
127
+
128
+ /** Renders a RateLimitError as the cross-sell banner; anything else as an error. */
129
+ function showError(err) {
130
+ if (err instanceof RateLimitError || (err && err.isRateLimit)) {
131
+ showRateLimit();
132
+ return;
133
+ }
134
+ const text = typeof err === "string" ? err : (err && err.message) || "Something went wrong.";
135
+ clearStatus();
136
+ status.appendChild(el("div", { className: "ms-banner ms-banner-error", text }));
137
+ }
138
+
139
+ function setLoading(isLoading, label) {
140
+ card.classList.toggle("is-loading", !!isLoading);
141
+ if (isLoading) {
142
+ clearStatus();
143
+ status.appendChild(
144
+ el("div", { className: "ms-banner ms-banner-loading" },
145
+ el("span", { className: "ms-spinner", "aria-hidden": "true" }),
146
+ el("span", { text: label || "Working…" }),
147
+ ),
148
+ );
149
+ } else {
150
+ clearStatus();
151
+ }
152
+ }
153
+
154
+ function clearOutput() {
155
+ output.replaceChildren();
156
+ }
157
+
158
+ return { form, output, status, setLoading, showError, showRateLimit, clearStatus, clearOutput, limitNote };
159
+ }
160
+
161
+ /** Standalone footer for non-tool spaces (e.g. the benchmark) that build their own body. */
162
+ export function mountChrome(toolKey) {
163
+ const root = document.getElementById("app") || document.body;
164
+ root.prepend(header());
165
+ root.appendChild(crossSell(toolKey));
166
+ }