mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add static demo site at demo.snapotter.com
- New apps/demo/ that reuses apps/web components with mocked API layer - Full UI shell: login, change password, analytics consent, dashboard, all tool pages - Stateful mock tracks session flow (password change, analytics consent) - Demo banner with link to GitHub repo - Processing attempts show info message with GitHub link - Deployed to Cloudflare Pages as static site (no backend) Also links demo across all surfaces: - README: "Live Demo" badge - Landing navbar: "Try Demo" CTA button (replaces "Book a Demo") - Landing hero: "No sign-ups. No credit card." tagline - Docs getting-started: "Try before installing" tip box Other changes: - Docs: move NVIDIA GPU section above GHCR, demote GHCR to collapsed details - Fix before-after slider checkerboard background for transparency - Fix remove-bg preview reset when no effects applied
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function DemoBanner() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="relative z-[9999] flex items-center justify-center gap-3 bg-blue-600 px-4 py-2 text-sm text-white">
|
||||
<span>
|
||||
This is a live demo. Processing is disabled.{" "}
|
||||
<a
|
||||
href="https://github.com/snapotter-hq/SnapOtter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline underline-offset-2 hover:text-blue-100"
|
||||
>
|
||||
Self-host SnapOtter
|
||||
</a>{" "}
|
||||
for full functionality.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="ms-2 shrink-0 rounded px-1.5 py-0.5 text-xs font-medium hover:bg-blue-700"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { installMocks } from "./mock-api";
|
||||
|
||||
installMocks();
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "@/App";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const DEMO_MARKER = "demo instance";
|
||||
const GITHUB_URL = "https://github.com/snapotter-hq/SnapOtter";
|
||||
const GITHUB_LABEL = "github.com/snapotter-hq/SnapOtter";
|
||||
|
||||
function patchDemoErrors(root: Element) {
|
||||
const observer = new MutationObserver(() => {
|
||||
root.querySelectorAll(".text-red-500, [data-type='error']").forEach((el) => {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
if (!el.textContent?.includes(DEMO_MARKER)) return;
|
||||
if (el.dataset.demoPatch) return;
|
||||
el.dataset.demoPatch = "1";
|
||||
el.classList.remove("text-red-500");
|
||||
el.style.color = "#3b82f6";
|
||||
el.style.fontSize = "13px";
|
||||
el.style.lineHeight = "1.5";
|
||||
|
||||
const text = el.textContent;
|
||||
const parts = text.split(GITHUB_LABEL);
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
el.appendChild(document.createTextNode(parts[0]));
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = GITHUB_URL;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = GITHUB_LABEL;
|
||||
link.style.fontWeight = "600";
|
||||
link.style.textDecoration = "underline";
|
||||
link.style.textUnderlineOffset = "2px";
|
||||
el.appendChild(link);
|
||||
|
||||
if (parts[1]) el.appendChild(document.createTextNode(parts[1]));
|
||||
});
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
patchDemoErrors(rootElement);
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DemoBanner />
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
const PERMISSIONS = [
|
||||
"tools:use",
|
||||
"files:own",
|
||||
"files:all",
|
||||
"apikeys:own",
|
||||
"apikeys:all",
|
||||
"pipelines:own",
|
||||
"pipelines:all",
|
||||
"settings:read",
|
||||
"settings:write",
|
||||
"users:manage",
|
||||
"teams:manage",
|
||||
"features:manage",
|
||||
"system:health",
|
||||
"audit:read",
|
||||
];
|
||||
|
||||
const FEATURE_BUNDLES = [
|
||||
{
|
||||
id: "background-removal",
|
||||
name: "Background Removal",
|
||||
description: "Remove image backgrounds with AI",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "4-5 GB",
|
||||
enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: "face-detection",
|
||||
name: "Face Detection",
|
||||
description: "Detect and blur faces, fix red-eye, smart crop",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "200-300 MB",
|
||||
enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: "object-eraser-colorize",
|
||||
name: "Object Eraser & Colorize",
|
||||
description: "Erase objects from photos and colorize B&W images",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "1-2 GB",
|
||||
enablesTools: ["erase-object", "colorize", "ai-canvas-expand"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: "upscale-enhance",
|
||||
name: "Upscale & Enhance",
|
||||
description: "AI upscaling, face enhancement, and noise removal",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "4-5 GB",
|
||||
enablesTools: ["upscale", "enhance-faces", "noise-removal"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: "photo-restoration",
|
||||
name: "Photo Restoration",
|
||||
description: "Restore old or damaged photos",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "800 MB - 1 GB",
|
||||
enablesTools: ["restore-photo"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: "ocr",
|
||||
name: "OCR",
|
||||
description: "Extract text from images",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "3-4 GB",
|
||||
enablesTools: ["ocr"],
|
||||
progress: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_KEY = "snapotter-demo-state";
|
||||
|
||||
function loadState(): {
|
||||
passwordChanged: boolean;
|
||||
analyticsEnabled: boolean | null;
|
||||
analyticsConsentShownAt: number | null;
|
||||
} {
|
||||
try {
|
||||
const raw = localStorage.getItem(STATE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return { passwordChanged: false, analyticsEnabled: null, analyticsConsentShownAt: null };
|
||||
}
|
||||
|
||||
function saveState(patch: Partial<ReturnType<typeof loadState>>) {
|
||||
const state = { ...loadState(), ...patch };
|
||||
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function matchRoute(url: string, method: string): Response | null {
|
||||
const path = new URL(url, "http://localhost").pathname;
|
||||
|
||||
if (path === "/api/v1/config/auth" && method === "GET") {
|
||||
return json({ authEnabled: true, oidcEnabled: false });
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" && method === "POST") {
|
||||
localStorage.setItem("snapotter-token", "demo-token");
|
||||
return json({ token: "demo-token" });
|
||||
}
|
||||
|
||||
if (path === "/api/auth/session" && method === "GET") {
|
||||
const token = localStorage.getItem("snapotter-token");
|
||||
if (!token) return json({ error: "Unauthorized" }, 401);
|
||||
const state = loadState();
|
||||
return json({
|
||||
user: {
|
||||
id: 1,
|
||||
username: "demo",
|
||||
displayName: "Demo User",
|
||||
role: "admin",
|
||||
permissions: PERMISSIONS,
|
||||
mustChangePassword: !state.passwordChanged,
|
||||
analyticsEnabled: state.analyticsEnabled,
|
||||
analyticsConsentShownAt: state.analyticsConsentShownAt,
|
||||
analyticsConsentRemindAt: null,
|
||||
loginMethod: "local",
|
||||
hasLocalPassword: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && method === "POST") {
|
||||
localStorage.removeItem("snapotter-token");
|
||||
localStorage.removeItem(STATE_KEY);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/health") {
|
||||
return json({ status: "ok", version: "1.17.0" });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/settings" && method === "GET") {
|
||||
return json({
|
||||
settings: {
|
||||
disabledTools: "[]",
|
||||
enableExperimentalTools: "false",
|
||||
defaultToolView: "sidebar",
|
||||
defaultTheme: "system",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/v1/features" && method === "GET") {
|
||||
return json({ bundles: FEATURE_BUNDLES });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/config/analytics" && method === "GET") {
|
||||
return json({
|
||||
enabled: true,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "demo-instance",
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/v1/user/analytics" && method === "PUT") {
|
||||
saveState({ analyticsEnabled: true, analyticsConsentShownAt: Date.now() });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/tools/") && method === "POST") {
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
"This is a demo instance. To process images, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (path === "/api/v1/upload" && method === "POST") {
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
"This is a demo instance. To upload files, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (path === "/api/v1/files" && method === "GET") {
|
||||
return json({ files: [], total: 0 });
|
||||
}
|
||||
|
||||
if (path === "/api/v1/files" && method === "DELETE") {
|
||||
return json({ deleted: 0 });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/admin/")) {
|
||||
return json({ error: "Admin actions are disabled in this demo." }, 403);
|
||||
}
|
||||
|
||||
if (path === "/api/auth/change-password" && method === "POST") {
|
||||
saveState({ passwordChanged: true });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/pipelines") && method === "GET") {
|
||||
return json({ pipelines: [] });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/pipelines") && method === "POST") {
|
||||
return json({ error: "Pipelines are disabled in this demo." }, 403);
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/audit") && method === "GET") {
|
||||
return json({ entries: [], total: 0 });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/users") && method === "GET") {
|
||||
return json({
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: "demo",
|
||||
displayName: "Demo User",
|
||||
role: "admin",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/teams") && method === "GET") {
|
||||
return json({ teams: [] });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/apikeys") && method === "GET") {
|
||||
return json({ apiKeys: [] });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/v1/roles") && method === "GET") {
|
||||
return json({ roles: [] });
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/")) {
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
export function installMocks() {
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const method = init?.method?.toUpperCase() || "GET";
|
||||
|
||||
const mock = matchRoute(url, method);
|
||||
if (mock) return mock;
|
||||
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
const OriginalXHR = window.XMLHttpRequest;
|
||||
const MockXHR = class extends OriginalXHR {
|
||||
private _url = "";
|
||||
private _method = "";
|
||||
|
||||
open(method: string, url: string | URL, ...args: unknown[]) {
|
||||
this._method = method.toUpperCase();
|
||||
this._url = typeof url === "string" ? url : url.href;
|
||||
// @ts-expect-error -- variadic override
|
||||
super.open(method, url, ...args);
|
||||
}
|
||||
|
||||
send(body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
if (this._url.startsWith("/api/")) {
|
||||
const mock = matchRoute(this._url, this._method);
|
||||
if (mock) {
|
||||
setTimeout(async () => {
|
||||
const responseText = await mock.text();
|
||||
Object.defineProperty(this, "status", { value: mock.status, writable: false });
|
||||
Object.defineProperty(this, "readyState", { value: 4, writable: false });
|
||||
Object.defineProperty(this, "responseText", { value: responseText, writable: false });
|
||||
Object.defineProperty(this, "response", { value: responseText, writable: false });
|
||||
this.dispatchEvent(new Event("loadstart"));
|
||||
this.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
|
||||
if (this.upload) {
|
||||
this.upload.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
|
||||
this.upload.dispatchEvent(new ProgressEvent("load", { loaded: 100, total: 100 }));
|
||||
}
|
||||
this.dispatchEvent(new Event("load"));
|
||||
this.dispatchEvent(new Event("loadend"));
|
||||
if (typeof this.onreadystatechange === "function") {
|
||||
this.onreadystatechange(new Event("readystatechange") as ProgressEvent);
|
||||
}
|
||||
if (typeof this.onload === "function") {
|
||||
this.onload(new ProgressEvent("load"));
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
super.send(body);
|
||||
}
|
||||
};
|
||||
|
||||
window.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest;
|
||||
|
||||
const originalSubmit = HTMLFormElement.prototype.submit;
|
||||
HTMLFormElement.prototype.submit = function () {
|
||||
if (this.method.toUpperCase() === "POST") {
|
||||
const target = this.action || window.location.href;
|
||||
const url = new URL(target, window.location.origin);
|
||||
window.location.href = url.pathname;
|
||||
return;
|
||||
}
|
||||
originalSubmit.call(this);
|
||||
};
|
||||
|
||||
const OriginalEventSource = window.EventSource;
|
||||
window.EventSource = class extends OriginalEventSource {
|
||||
constructor(url: string | URL, init?: EventSourceInit) {
|
||||
const urlStr = typeof url === "string" ? url : url.href;
|
||||
if (urlStr.startsWith("/api/")) {
|
||||
super("about:blank", init);
|
||||
setTimeout(() => this.close(), 0);
|
||||
return;
|
||||
}
|
||||
super(url, init);
|
||||
}
|
||||
} as typeof EventSource;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../web/src/**/*.tsx";
|
||||
@source "../../../web/src/**/*.ts";
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK TC",
|
||||
"Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Arabic",
|
||||
"Noto Sans Devanagari", "Noto Sans Thai", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0f172a;
|
||||
--color-sidebar: #f8fafc;
|
||||
--color-sidebar-foreground: #334155;
|
||||
--color-accent: #3b82f6;
|
||||
--color-destructive: #ef4444;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #0f172a;
|
||||
--color-foreground: #f8fafc;
|
||||
--color-muted: #1e293b;
|
||||
--color-muted-foreground: #94a3b8;
|
||||
--color-border: #334155;
|
||||
--color-card: #1e293b;
|
||||
--color-card-foreground: #f8fafc;
|
||||
--color-sidebar: #1e293b;
|
||||
--color-sidebar-foreground: #cbd5e1;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-background);
|
||||
box-shadow: 0 0 0 1px var(--color-border);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-background);
|
||||
box-shadow: 0 0 0 1px var(--color-border);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir="rtl"] input[type="text"],
|
||||
[dir="rtl"] input[type="password"],
|
||||
[dir="rtl"] input[type="email"],
|
||||
[dir="rtl"] input[type="number"],
|
||||
[dir="rtl"] input[type="search"],
|
||||
[dir="rtl"] textarea,
|
||||
[dir="rtl"] select {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir="rtl"] input[type="range"] {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
[dir="rtl"] pre,
|
||||
[dir="rtl"] code {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
Reference in New Issue
Block a user