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:
SnapOtter
2026-05-17 09:25:12 +08:00
parent df2d1286d6
commit 228f70d011
20 changed files with 697 additions and 16 deletions
+1
View File
@@ -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>
+23
View File
@@ -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>
+25
View File
@@ -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"
}
}
+1
View File
@@ -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

+32
View File
@@ -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>
);
}
+56
View File
@@ -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>,
);
+351
View File
@@ -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;
}
+108
View File
@@ -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;
}
+14
View File
@@ -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/**/*"]
}
+21
View File
@@ -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",
},
});
+12 -8
View File
@@ -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
+2 -1
View File
@@ -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() {
&rarr; &rarr;
</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>
+8 -4
View File
@@ -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;
} }
+34
View File
@@ -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: