diff --git a/README.md b/README.md index 1e9a651f..9bc2a09d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ CI License Stars + Live Demo Discord Sponsor

diff --git a/apps/demo/index.html b/apps/demo/index.html new file mode 100644 index 00000000..3d1709c0 --- /dev/null +++ b/apps/demo/index.html @@ -0,0 +1,23 @@ + + + + + + SnapOtter Demo + + + + + + + + + + + + + +
+ + + diff --git a/apps/demo/package.json b/apps/demo/package.json new file mode 100644 index 00000000..2277e56b --- /dev/null +++ b/apps/demo/package.json @@ -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" + } +} diff --git a/apps/demo/public/_redirects b/apps/demo/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/apps/demo/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/apps/demo/public/apple-touch-icon.png b/apps/demo/public/apple-touch-icon.png new file mode 100644 index 00000000..084ec2a6 Binary files /dev/null and b/apps/demo/public/apple-touch-icon.png differ diff --git a/apps/demo/public/favicon.ico b/apps/demo/public/favicon.ico new file mode 100644 index 00000000..030576fc Binary files /dev/null and b/apps/demo/public/favicon.ico differ diff --git a/apps/demo/public/favicon.png b/apps/demo/public/favicon.png new file mode 100644 index 00000000..0ccaa93b Binary files /dev/null and b/apps/demo/public/favicon.png differ diff --git a/apps/demo/public/logo.png b/apps/demo/public/logo.png new file mode 100644 index 00000000..02115384 Binary files /dev/null and b/apps/demo/public/logo.png differ diff --git a/apps/demo/src/demo-banner.tsx b/apps/demo/src/demo-banner.tsx new file mode 100644 index 00000000..c9d22d10 --- /dev/null +++ b/apps/demo/src/demo-banner.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; + +export function DemoBanner() { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) return null; + + return ( +
+ + This is a live demo. Processing is disabled.{" "} + + Self-host SnapOtter + {" "} + for full functionality. + + +
+ ); +} diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx new file mode 100644 index 00000000..e1862151 --- /dev/null +++ b/apps/demo/src/main.tsx @@ -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( + + + + , +); diff --git a/apps/demo/src/mock-api.ts b/apps/demo/src/mock-api.ts new file mode 100644 index 00000000..95c24ac5 --- /dev/null +++ b/apps/demo/src/mock-api.ts @@ -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>) { + 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 => { + 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; +} diff --git a/apps/demo/src/styles/globals.css b/apps/demo/src/styles/globals.css new file mode 100644 index 00000000..04621971 --- /dev/null +++ b/apps/demo/src/styles/globals.css @@ -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; +} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json new file mode 100644 index 00000000..4a49bb11 --- /dev/null +++ b/apps/demo/tsconfig.json @@ -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/**/*"] +} diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts new file mode 100644 index 00000000..2fa363e7 --- /dev/null +++ b/apps/demo/vite.config.ts @@ -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", + }, +}); diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md index bd22e74c..4d217bfe 100644 --- a/apps/docs/guide/getting-started.md +++ b/apps/docs/guide/getting-started.md @@ -4,6 +4,10 @@ description: Install SnapOtter with Docker in one command. Includes Docker Compo # 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 ```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. -::: 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 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. ::: +::: 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 ```yaml diff --git a/apps/landing/src/components/hero.tsx b/apps/landing/src/components/hero.tsx index 6abcd50e..c8930790 100644 --- a/apps/landing/src/components/hero.tsx +++ b/apps/landing/src/components/hero.tsx @@ -126,7 +126,7 @@ export function Hero() {

-
+
+

No sign-ups. No credit card.

diff --git a/apps/landing/src/components/navbar.tsx b/apps/landing/src/components/navbar.tsx index 54f6b891..8e2a4e17 100644 --- a/apps/landing/src/components/navbar.tsx +++ b/apps/landing/src/components/navbar.tsx @@ -92,10 +92,12 @@ export function Navbar() { )} - Book a Demo + Try Demo @@ -142,10 +144,12 @@ export function Navbar() { Star on GitHub - Book a Demo + Try Demo diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index 239c48a4..41aa5184 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -117,8 +117,14 @@ export function BeforeAfterSlider({ {/* After image (clipped, top layer) */}