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:
@@ -8,6 +8,7 @@
|
|||||||
<a href="https://github.com/snapotter-hq/snapotter/actions"><img src="https://img.shields.io/github/actions/workflow/status/snapotter-hq/snapotter/ci.yml?label=CI" alt="CI"></a>
|
<a href="https://github.com/snapotter-hq/snapotter/actions"><img src="https://img.shields.io/github/actions/workflow/status/snapotter-hq/snapotter/ci.yml?label=CI" alt="CI"></a>
|
||||||
<a href="https://github.com/snapotter-hq/snapotter/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPLv3-blue" alt="License"></a>
|
<a href="https://github.com/snapotter-hq/snapotter/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPLv3-blue" alt="License"></a>
|
||||||
<a href="https://github.com/snapotter-hq/snapotter/stargazers"><img src="https://img.shields.io/github/stars/snapotter-hq/snapotter?style=social" alt="Stars"></a>
|
<a href="https://github.com/snapotter-hq/snapotter/stargazers"><img src="https://img.shields.io/github/stars/snapotter-hq/snapotter?style=social" alt="Stars"></a>
|
||||||
|
<a href="https://demo.snapotter.com"><img src="https://img.shields.io/badge/Live%20Demo-Try%20it-blue?logo=googlechrome&logoColor=white" alt="Live Demo"></a>
|
||||||
<a href="https://discord.gg/hr3s7HPUsr"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/hr3s7HPUsr"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
<a href="https://github.com/sponsors/snapotter-hq"><img src="https://img.shields.io/badge/Sponsor-pink?logo=githubsponsors&logoColor=white" alt="Sponsor"></a>
|
<a href="https://github.com/sponsors/snapotter-hq"><img src="https://img.shields.io/badge/Sponsor-pink?logo=githubsponsors&logoColor=white" alt="Sponsor"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SnapOtter Demo</title>
|
||||||
|
<meta name="description" content="Try SnapOtter - open-source, self-hosted image processing platform. Live demo." />
|
||||||
|
<meta name="theme-color" content="#3b82f6" />
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="/favicon.png" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta property="og:title" content="SnapOtter Demo" />
|
||||||
|
<meta property="og:description" content="Try SnapOtter - open-source, self-hosted image processing. 52 tools, 100% local." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://demo.snapotter.com" />
|
||||||
|
<meta property="og:image" content="https://snapotter.com/og-image.png" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@snapotter/demo",
|
||||||
|
"version": "1.17.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@snapotter/shared": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["../web/src/*"]
|
||||||
|
},
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "../web/src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "../web/src"),
|
||||||
|
},
|
||||||
|
dedupe: ["react", "react-dom"],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 1352,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ description: Install SnapOtter with Docker in one command. Includes Docker Compo
|
|||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
|
::: tip Try before installing
|
||||||
|
Explore the full UI at [demo.snapotter.com](https://demo.snapotter.com) -- no signup or install required.
|
||||||
|
:::
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -12,14 +16,6 @@ docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/sn
|
|||||||
|
|
||||||
You will be asked to change your password on first login.
|
You will be asked to change your password on first login.
|
||||||
|
|
||||||
::: tip Also on GHCR
|
|
||||||
```bash
|
|
||||||
docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Both registries publish the same image on every release.
|
|
||||||
:::
|
|
||||||
|
|
||||||
::: tip NVIDIA GPU acceleration
|
::: tip NVIDIA GPU acceleration
|
||||||
Add `--gpus all` for GPU-accelerated background removal, upscaling, OCR, face enhancement, and restoration:
|
Add `--gpus all` for GPU-accelerated background removal, upscaling, OCR, face enhancement, and restoration:
|
||||||
|
|
||||||
@@ -30,6 +26,14 @@ docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data s
|
|||||||
Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU automatically. See [Docker Tags](/guide/docker-tags) for benchmarks.
|
Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU automatically. See [Docker Tags](/guide/docker-tags) for benchmarks.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
::: details Also on GHCR
|
||||||
|
```bash
|
||||||
|
docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Both registries publish the same image on every release.
|
||||||
|
:::
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function Hero() {
|
|||||||
<TypingCursor />
|
<TypingCursor />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 animate-[fadeUp_0.6s_ease-out_0.3s_both]">
|
<div className="mt-10 animate-[fadeUp_0.6s_ease-out_0.3s_both] text-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/snapotter-hq/snapotter"
|
href="https://github.com/snapotter-hq/snapotter"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -138,6 +138,7 @@ export function Hero() {
|
|||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
<p className="mt-4 text-sm text-muted">No sign-ups. No credit card.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ export function Navbar() {
|
|||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/contact"
|
href="https://demo.snapotter.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="rounded-lg bg-accent px-4 py-1.5 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent-hover"
|
className="rounded-lg bg-accent px-4 py-1.5 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
Book a Demo
|
Try Demo
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,10 +144,12 @@ export function Navbar() {
|
|||||||
Star on GitHub
|
Star on GitHub
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/contact"
|
href="https://demo.snapotter.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="block rounded-lg bg-accent px-4 py-2 text-center text-sm font-medium text-accent-foreground"
|
className="block rounded-lg bg-accent px-4 py-2 text-center text-sm font-medium text-accent-foreground"
|
||||||
>
|
>
|
||||||
Book a Demo
|
Try Demo
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ export function BeforeAfterSlider({
|
|||||||
|
|
||||||
{/* After image (clipped, top layer) */}
|
{/* After image (clipped, top layer) */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-muted/30"
|
className="absolute inset-0"
|
||||||
style={{ clipPath: `inset(0 0 0 ${position}%)` }}
|
style={{
|
||||||
|
clipPath: `inset(0 0 0 ${position}%)`,
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={afterSrc}
|
src={afterSrc}
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
const shadowOpacity = (settings.shadowOpacity as number) ?? 35;
|
const shadowOpacity = (settings.shadowOpacity as number) ?? 35;
|
||||||
|
|
||||||
if (!blurEnabled && !shadowEnabled && bgType === "transparent") {
|
if (!blurEnabled && !shadowEnabled && bgType === "transparent") {
|
||||||
onBgPreview({ showCheckerboard: true });
|
onBgPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+34
@@ -218,6 +218,40 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/demo:
|
||||||
|
dependencies:
|
||||||
|
'@snapotter/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
react:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.4
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.4(react@19.2.4)
|
||||||
|
devDependencies:
|
||||||
|
'@tailwindcss/vite':
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.14
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^4.3.0
|
||||||
|
version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.2.4
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
apps/docs:
|
apps/docs:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
tsx:
|
tsx:
|
||||||
|
|||||||
Reference in New Issue
Block a user