Spaces:
Running
Running
Deploy static space build
Browse files- README.md +55 -4
- app.js +213 -0
- index.html +32 -17
- shared/api-client.js +114 -0
- shared/config.js +84 -0
- shared/ui.css +250 -0
- shared/ui.js +166 -0
README.md
CHANGED
|
@@ -1,10 +1,61 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|