Merge feat/landing-page into main

Integrates the SnapOtter landing page with Nunito typography,
otter mascot logo, and teal open-source section accent.
This commit is contained in:
ashim-hq
2026-04-24 15:16:49 +08:00
35 changed files with 3688 additions and 4 deletions
+2
View File
@@ -21,6 +21,8 @@ __pycache__/
*.pyc
# Build artifacts
.next/
out/
coverage/
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@ashim/landing",
"version": "1.15.9",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 1350",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"framer-motion": "^11.18.0",
"lucide-react": "^0.469.0",
"next": "^15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<text x="600" y="280" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="72" font-weight="800" fill="white">SnapOtter</text>
<text x="600" y="350" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="28" font-weight="400" fill="rgba(255,255,255,0.85)">Self-Hosted Image Processing</text>
<text x="600" y="400" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="400" fill="rgba(255,255,255,0.6)">50+ Tools · Local AI · 100% Offline · Open Source</text>
</svg>

After

Width:  |  Height:  |  Size: 935 B

+237
View File
@@ -0,0 +1,237 @@
"use client";
import { CheckCircle, FileText, Loader2, Monitor, Shield } from "lucide-react";
import type { FormEvent } from "react";
import { useState } from "react";
import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer";
import { Navbar } from "@/components/navbar";
const benefits = [
{
icon: Monitor,
title: "Live Demo",
description: "See SnapOtter in action with a personalized walkthrough of all features.",
},
{
icon: Shield,
title: "Deployment Support",
description: "Get expert help setting up SnapOtter on your infrastructure.",
},
{
icon: FileText,
title: "Enterprise Licensing",
description: "Custom agreements for proprietary use, OEM, and commercial distribution.",
},
];
const subjects = ["Book a Demo", "Enterprise Licensing", "Deployment Help", "General Inquiry"];
const FORMSPREE_URL = "https://formspree.io/f/mykllwek";
export default function ContactPage() {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setError("");
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(FORMSPREE_URL, {
method: "POST",
body: formData,
headers: { Accept: "application/json" },
});
if (res.ok) {
setSubmitted(true);
} else {
setError("Something went wrong. Please try again or email us directly.");
}
} catch {
setError("Network error. Please try again or email us directly.");
} finally {
setSubmitting(false);
}
}
return (
<>
<Navbar />
<main className="pt-16">
<section className="px-6 py-24 md:py-32">
<div className="mx-auto grid max-w-5xl gap-16 md:grid-cols-2">
{/* Left column */}
<FadeIn>
<div>
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Get in touch</h1>
<p className="mt-6 text-lg leading-relaxed text-muted">
Whether you need a demo, have questions about deployment, or want to discuss
enterprise licensing, we are here to help.
</p>
<div className="mt-12 space-y-6">
{benefits.map((item) => (
<div
key={item.title}
className="flex gap-4 rounded-xl border border-border p-5"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<item.icon size={20} className="text-accent" />
</div>
<div>
<h3 className="font-semibold">{item.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-muted">
{item.description}
</p>
</div>
</div>
))}
</div>
<p className="mt-10 text-sm text-muted">
Open source under AGPL-3.0. Commercial licenses available.
</p>
</div>
</FadeIn>
{/* Right column */}
<FadeIn delay={0.1}>
{submitted ? (
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-border bg-background-alt p-12 text-center">
<CheckCircle size={48} className="text-emerald-500" />
<h2 className="mt-6 text-2xl font-bold">Message sent!</h2>
<p className="mt-3 text-muted">
Thanks for reaching out. We will get back to you within 24 hours.
</p>
<button
type="button"
onClick={() => setSubmitted(false)}
className="mt-8 rounded-lg border border-border px-6 py-2.5 text-sm font-medium transition-colors hover:bg-background"
>
Send another message
</button>
</div>
) : (
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-border bg-background-alt p-8"
>
<div className="space-y-5">
<div>
<label htmlFor="name" className="mb-1.5 block text-sm font-medium">
Name <span className="text-accent">*</span>
</label>
<input
id="name"
type="text"
name="name"
required
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
Email <span className="text-accent">*</span>
</label>
<input
id="email"
type="email"
name="email"
required
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
<div>
<label htmlFor="company" className="mb-1.5 block text-sm font-medium">
Company <span className="text-accent">*</span>
</label>
<input
id="company"
type="text"
name="company"
required
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
<div>
<label htmlFor="subject" className="mb-1.5 block text-sm font-medium">
Subject
</label>
<select
id="subject"
name="subject"
defaultValue={subjects[0]}
className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent"
>
{subjects.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label htmlFor="message" className="mb-1.5 block text-sm font-medium">
Message <span className="text-accent">*</span>
</label>
<textarea
id="message"
name="message"
required
rows={5}
className="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-sm outline-none transition-colors focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
{error && (
<p className="rounded-lg bg-red-50 px-4 py-2.5 text-sm text-red-600">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-accent py-3 text-sm font-bold text-accent-foreground transition-colors hover:bg-accent-hover disabled:opacity-60"
>
{submitting ? (
<>
<Loader2 size={16} className="animate-spin" />
Sending...
</>
) : (
"Send Message"
)}
</button>
</div>
<p className="mt-5 text-center text-sm text-muted">
Or email us directly at{" "}
<a
href="mailto:contact@snapotter.com"
className="text-accent underline underline-offset-4 hover:text-accent-hover"
>
contact@snapotter.com
</a>
</p>
</form>
)}
</FadeIn>
</div>
</section>
</main>
<Footer />
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer";
import { Navbar } from "@/components/navbar";
const faqs = [
{
question: "Are my files safe and private?",
answer:
"All processing happens on your own server. Your images are never sent to any external service. SnapOtter runs entirely on your infrastructure.",
},
{
question: "Is SnapOtter really free?",
answer:
"Yes. SnapOtter is open source under AGPL-3.0 with no hidden fees, no subscription plans, no usage limits, and no premium-only features. A commercial license is available for organizations that need proprietary use.",
},
{
question: "Do I need an internet connection?",
answer:
"No. SnapOtter runs fully offline once deployed. No external API calls are made. It works in completely air-gapped environments.",
},
{
question: "Are there any file size or usage limitations?",
answer:
"No artificial limits. You can process as many images as you want, at any size. The only constraint is your server's hardware resources.",
},
{
question: "What AI features are included?",
answer:
"SnapOtter includes 14 local AI models for background removal, upscaling, OCR, face detection, colorization, noise removal, photo restoration, and more. All models run on your hardware.",
},
{
question: "Does SnapOtter collect any analytics?",
answer:
"Analytics is completely optional and disabled by default. If you choose to enable it, SnapOtter collects anonymous usage data and error logs to help improve the software. No personal data or image content is ever collected. You have full control over this in the settings and can disable it at any time.",
},
{
question: "Can I use SnapOtter in my company?",
answer:
"Yes. Under AGPL-3.0, you can use it freely as long as you comply with the license terms. For proprietary or closed-source use, a commercial license is available.",
},
{
question: "How do I update SnapOtter?",
answer:
"Pull the latest Docker image and restart the container. Your data persists in the mounted volume.",
},
];
function FaqItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-border">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between py-5 text-left"
>
<span className="text-base font-medium">{question}</span>
<ChevronDown
size={18}
className={`shrink-0 text-muted transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && <p className="pb-5 text-sm leading-relaxed text-muted">{answer}</p>}
</div>
);
}
export default function FaqPage() {
return (
<>
<Navbar />
<main className="pt-16">
<section className="px-6 py-24 md:py-32">
<div className="mx-auto max-w-3xl">
<FadeIn>
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">
Frequently Asked Questions
</h1>
<p className="mt-6 text-lg text-muted">
Everything you need to know about SnapOtter.
</p>
</div>
</FadeIn>
<FadeIn delay={0.1}>
<div className="mt-16 border-t border-border">
{faqs.map((faq) => (
<FaqItem key={faq.question} question={faq.question} answer={faq.answer} />
))}
</div>
</FadeIn>
</div>
</section>
</main>
<Footer />
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { Inter, Nunito } from "next/font/google";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
export const metadata: Metadata = {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
metadataBase: new URL("https://snapotter.com"),
openGraph: {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network.",
url: "https://snapotter.com",
siteName: "SnapOtter",
type: "website",
images: [
{
url: "/og-image.svg",
width: 1200,
height: 630,
alt: "SnapOtter | Self-Hosted Image Processing",
},
],
},
twitter: {
card: "summary_large_image",
title: "SnapOtter | Self-Hosted Image Processing",
description:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network.",
images: ["/og-image.svg"],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${nunito.variable}`} suppressHydrationWarning>
<body
className="bg-background text-foreground font-[family-name:var(--font-inter)] antialiased"
suppressHydrationWarning
>
{children}
</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { BentoGrid } from "@/components/bento-grid";
import { Enterprise } from "@/components/enterprise";
import { Footer } from "@/components/footer";
import { Hero } from "@/components/hero";
import { HowItWorks } from "@/components/how-it-works";
import { Navbar } from "@/components/navbar";
import { OpenSource } from "@/components/open-source";
import { Pricing } from "@/components/pricing";
import { WhyChoose } from "@/components/why-choose";
export default function Home() {
return (
<>
<Navbar />
<main>
<Hero />
<HowItWorks />
<WhyChoose />
<BentoGrid />
<Enterprise />
<Pricing />
<OpenSource />
</main>
<Footer />
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer";
import { Navbar } from "@/components/navbar";
export const metadata: Metadata = {
title: "Privacy Policy | SnapOtter",
description:
"SnapOtter is self-hosted software. We do not process, store, or have access to your images or data. Learn about our privacy practices.",
};
export default function PrivacyPage() {
return (
<>
<Navbar />
<main className="pt-16">
<section className="px-6 py-24 md:py-32">
<div className="mx-auto max-w-3xl">
<FadeIn>
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">Privacy Policy</h1>
<p className="mt-6 text-lg text-muted">Last updated: April 24, 2026</p>
</div>
</FadeIn>
<div className="mt-16 space-y-12 text-sm leading-relaxed text-muted">
<section>
<h2 className="text-lg font-semibold text-foreground">Overview</h2>
<p className="mt-3">
SnapOtter is self-hosted software. We do not process, store, or have access to
your images or data. This privacy policy covers the snapotter.com website and
optional analytics within the software.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Website (snapotter.com)</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>We do not use tracking cookies.</li>
<li>We do not use third-party analytics on the website.</li>
<li>
Contact form submissions are processed via Formspree. Their privacy policy
applies to that data.
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Self-Hosted Software</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>All image processing happens entirely on your server.</li>
<li>No data is sent to SnapOtter or any third party.</li>
<li>There is no telemetry by default.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Optional Analytics</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>Analytics is disabled by default.</li>
<li>
When enabled, it collects anonymous usage data (feature usage, error logs) to
help improve the software.
</li>
<li>No personal data, image content, or file names are collected.</li>
<li>You can disable analytics at any time in settings.</li>
<li>Analytics data is processed by PostHog.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Contact Form</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>
When you submit the contact form, your name, email, company, and message are
sent to us via Formspree.
</li>
<li>We use this information only to respond to your inquiry.</li>
<li>We do not sell or share your contact information.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Open Source</h2>
<p className="mt-3">
SnapOtter is open source. You can inspect the entire codebase to verify our
privacy practices.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Changes</h2>
<p className="mt-3">
We may update this policy. Changes will be posted on this page.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Contact</h2>
<p className="mt-3">
If you have questions about this privacy policy, contact us at{" "}
<a
href="mailto:contact@snapotter.com"
className="text-accent underline underline-offset-4 hover:text-accent-hover"
>
contact@snapotter.com
</a>
.
</p>
</section>
</div>
</div>
</section>
</main>
<Footer />
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { FadeIn } from "@/components/fade-in";
import { Footer } from "@/components/footer";
import { Navbar } from "@/components/navbar";
export const metadata: Metadata = {
title: "Terms and Conditions | SnapOtter",
description:
"Terms governing your use of the SnapOtter website and self-hosted image processing software.",
};
export default function TermsPage() {
return (
<>
<Navbar />
<main className="pt-16">
<section className="px-6 py-24 md:py-32">
<div className="mx-auto max-w-3xl">
<FadeIn>
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl">
Terms and Conditions
</h1>
<p className="mt-6 text-lg text-muted">Last updated: April 24, 2026</p>
</div>
</FadeIn>
<div className="mt-16 space-y-12 text-sm leading-relaxed text-muted">
<section>
<h2 className="text-lg font-semibold text-foreground">Overview</h2>
<p className="mt-3">
These terms govern your use of the SnapOtter website and software.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Software License</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>SnapOtter is licensed under AGPL-3.0.</li>
<li>
You may use, modify, and distribute the software under the terms of this
license.
</li>
<li>
For proprietary or commercial use without AGPL obligations, a commercial license
is available.
</li>
<li>
Contact us at{" "}
<a
href="mailto:contact@snapotter.com"
className="text-accent underline underline-offset-4 hover:text-accent-hover"
>
contact@snapotter.com
</a>{" "}
for commercial licensing terms.
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Website Use</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>The snapotter.com website is provided for informational purposes.</li>
<li>You may not use the website for any unlawful purpose.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Self-Hosted Software</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>You are responsible for your own deployment, data, and security.</li>
<li>SnapOtter is provided &quot;as is&quot; without warranty of any kind.</li>
<li>We are not liable for any damages arising from your use of the software.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Intellectual Property</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>SnapOtter, the SnapOtter logo, and related marks are our property.</li>
<li>The software source code is available under AGPL-3.0.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Limitation of Liability</h2>
<p className="mt-3">
To the maximum extent permitted by law, we are not liable for any indirect,
incidental, or consequential damages.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Changes</h2>
<p className="mt-3">
We may update these terms. Continued use constitutes acceptance.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-foreground">Contact</h2>
<p className="mt-3">
If you have questions about these terms, contact us at{" "}
<a
href="mailto:contact@snapotter.com"
className="text-accent underline underline-offset-4 hover:text-accent-hover"
>
contact@snapotter.com
</a>
.
</p>
</section>
</div>
</div>
</section>
</main>
<Footer />
</>
);
}
+469
View File
@@ -0,0 +1,469 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
AppWindow,
AudioLines,
Code,
Columns,
Columns2,
Copy,
Crop,
Eraser,
Eye,
EyeOff,
FileEdit,
FileImage,
FileOutput,
FileText,
Film,
Focus,
Frame,
Globe,
Grid3x3,
ImagePlus,
Info,
Layers,
LayoutGrid,
Maximize2,
Minimize2,
Palette,
PenLine,
PenTool,
Pipette,
QrCode,
RotateCw,
Scan,
ScanFace,
ScanLine,
ScanText,
Search,
ShieldOff,
SlidersHorizontal,
Sparkles,
TextCursorInput,
Type,
Undo2,
UserCheck,
Wand2,
ZoomIn,
} from "lucide-react";
import { useMemo, useState } from "react";
import { FadeIn } from "./fade-in";
const categories = [
{ id: "essentials", name: "Essentials", color: "#3B82F6" },
{ id: "optimization", name: "Optimization", color: "#10B981" },
{ id: "adjustments", name: "Adjustments", color: "#8B5CF6" },
{ id: "watermark", name: "Watermark & Overlay", color: "#EF4444" },
{ id: "utilities", name: "Utilities", color: "#6366F1" },
{ id: "layout", name: "Layout & Composition", color: "#EC4899" },
{ id: "format", name: "Format & Conversion", color: "#14B8A6" },
{ id: "ai", name: "AI Tools", color: "#F59E0B" },
];
const tools: { name: string; description: string; category: string; icon: LucideIcon }[] = [
// Essentials
{
name: "Resize",
description: "Resize by pixels, percentage, or social media presets",
category: "essentials",
icon: Maximize2,
},
{
name: "Crop",
description: "Freeform crop, aspect ratio presets, shape crop",
category: "essentials",
icon: Crop,
},
{
name: "Rotate & Flip",
description: "Rotate, flip, and straighten images",
category: "essentials",
icon: RotateCw,
},
{
name: "Convert",
description: "Convert between image formats",
category: "essentials",
icon: FileOutput,
},
{
name: "Compress",
description: "Reduce file size by quality or target size",
category: "essentials",
icon: Minimize2,
},
// Optimization
{
name: "Optimize for Web",
description:
"Optimize images for web with format conversion, quality control, and live preview",
category: "optimization",
icon: Globe,
},
{
name: "Remove Metadata",
description: "Remove EXIF, GPS, and camera info",
category: "optimization",
icon: ShieldOff,
},
{
name: "Edit Metadata",
description: "Edit EXIF, GPS, and camera info",
category: "optimization",
icon: PenLine,
},
{
name: "Bulk Rename",
description: "Rename multiple files with patterns",
category: "optimization",
icon: FileEdit,
},
{
name: "Image to PDF",
description: "Combine images into a PDF document",
category: "optimization",
icon: FileText,
},
{
name: "Favicon Generator",
description: "Generate all favicon and app icon sizes",
category: "optimization",
icon: AppWindow,
},
// Adjustments
{
name: "Adjust Colors",
description: "Brightness, contrast, exposure, saturation, temperature, sharpness, and effects",
category: "adjustments",
icon: SlidersHorizontal,
},
{
name: "Sharpening",
description: "Adaptive, unsharp mask, and high-pass sharpening with presets",
category: "adjustments",
icon: Focus,
},
{
name: "Replace & Invert Color",
description: "Replace specific colors or invert",
category: "adjustments",
icon: Pipette,
},
// AI Tools
{
name: "Remove Background",
description: "AI-powered background removal",
category: "ai",
icon: Eraser,
},
{
name: "Image Upscaling",
description: "AI super-resolution enhancement",
category: "ai",
icon: ZoomIn,
},
{
name: "Object Eraser",
description: "Remove unwanted objects with AI",
category: "ai",
icon: Wand2,
},
{
name: "OCR / Text Extraction",
description: "Extract text from images",
category: "ai",
icon: ScanText,
},
{
name: "Face / PII Blur",
description: "Auto-detect and blur faces and sensitive info",
category: "ai",
icon: EyeOff,
},
{
name: "Smart Crop",
description: "Smart subject, face, or trim-based cropping",
category: "ai",
icon: Scan,
},
{
name: "Image Enhancement",
description: "One-click auto-improve with smart analysis",
category: "ai",
icon: Sparkles,
},
{
name: "Face Enhancement",
description: "Restore and enhance faces with AI",
category: "ai",
icon: ScanFace,
},
{
name: "AI Colorization",
description: "Convert B&W photos to full color with AI",
category: "ai",
icon: Palette,
},
{
name: "Noise Removal",
description: "AI-powered noise and grain removal",
category: "ai",
icon: AudioLines,
},
{
name: "Red Eye Removal",
description: "AI-detect and fix red eye in flash photos",
category: "ai",
icon: Eye,
},
{
name: "Photo Restoration",
description: "Fix scratches, tears, and damage on old photos with AI",
category: "ai",
icon: Undo2,
},
{
name: "Passport Photo",
description: "AI-powered passport and ID photo generator",
category: "ai",
icon: UserCheck,
},
// Watermark & Overlay
{
name: "Text Watermark",
description: "Add text watermark overlay",
category: "watermark",
icon: Type,
},
{
name: "Image Watermark",
description: "Overlay a logo as watermark",
category: "watermark",
icon: ImagePlus,
},
{
name: "Text Overlay",
description: "Add styled text to images",
category: "watermark",
icon: TextCursorInput,
},
{
name: "Image Composition",
description: "Layer images with position and opacity",
category: "watermark",
icon: Layers,
},
// Utilities
{
name: "Image Info",
description: "View all metadata and image properties",
category: "utilities",
icon: Info,
},
{
name: "Image Compare",
description: "Side-by-side comparison of two images",
category: "utilities",
icon: Columns2,
},
{
name: "Find Duplicates",
description: "Detect duplicate and near-duplicate images",
category: "utilities",
icon: Copy,
},
{
name: "Color Palette",
description: "Extract dominant colors from image",
category: "utilities",
icon: Palette,
},
{
name: "QR Code Generator",
description: "Generate styled QR codes with custom colors, patterns, and logos",
category: "utilities",
icon: QrCode,
},
{
name: "Barcode Reader",
description: "Scan images for QR codes, barcodes, and 2D codes",
category: "utilities",
icon: ScanLine,
},
{
name: "Image to Base64",
description: "Convert images to base64 strings for embedding in HTML, CSS, and more",
category: "utilities",
icon: Code,
},
// Layout & Composition
{
name: "Collage / Grid",
description: "Combine images into beautiful grid collages with 25+ templates",
category: "layout",
icon: LayoutGrid,
},
{
name: "Stitch / Combine",
description: "Join images side by side, stacked, or in a grid",
category: "layout",
icon: Columns,
},
{
name: "Image Splitting",
description: "Split images into grid tiles or by pixel size with live preview",
category: "layout",
icon: Grid3x3,
},
{
name: "Border & Frame",
description: "Add borders, rounded corners, shadows",
category: "layout",
icon: Frame,
},
// Format & Conversion
{
name: "SVG to Raster",
description: "Convert SVG to PNG, JPEG, WebP, AVIF, TIFF, GIF, or HEIF at custom scale and DPI",
category: "format",
icon: FileImage,
},
{
name: "Image to SVG",
description: "Vectorize images using tracing",
category: "format",
icon: PenTool,
},
{
name: "GIF Tools",
description:
"Resize, optimize, change speed, reverse, extract frames, and rotate animated GIFs",
category: "format",
icon: Film,
},
{
name: "PDF to Image",
description: "Convert PDF pages to images",
category: "format",
icon: FileOutput,
},
];
const categoryMap = new Map(categories.map((c) => [c.id, c]));
function getCategoryCount(id: string) {
return tools.filter((t) => t.category === id).length;
}
export function BentoGrid() {
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState("all");
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
return tools.filter((tool) => {
if (activeCategory !== "all" && tool.category !== activeCategory) {
return false;
}
if (!q) return true;
const cat = categoryMap.get(tool.category);
return (
tool.name.toLowerCase().includes(q) ||
tool.description.toLowerCase().includes(q) ||
(cat?.name.toLowerCase().includes(q) ?? false)
);
});
}, [search, activeCategory]);
return (
<section id="features" className="px-6 py-24 md:py-36">
<div className="mx-auto max-w-6xl">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
40+ tools. Zero cloud dependency.
</h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
Search to find exactly what you need. Every tool runs 100% locally.
</p>
</FadeIn>
{/* Search bar */}
<div className="mx-auto mt-12 max-w-md">
<div className="relative">
<Search size={18} className="absolute top-1/2 left-4 -translate-y-1/2 text-muted" />
<input
type="text"
placeholder="Search tools..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-border bg-background py-3 pr-4 pl-11 text-sm outline-none transition-colors placeholder:text-muted focus:border-accent"
/>
</div>
</div>
{/* Category pills */}
<div className="mt-6 flex flex-nowrap gap-2 overflow-x-auto pb-2 md:flex-wrap md:justify-center md:overflow-visible">
<button
type="button"
onClick={() => setActiveCategory("all")}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-accent text-accent-foreground"
: "border border-border hover:bg-background-alt"
}`}
>
All ({tools.length})
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setActiveCategory(cat.id)}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
activeCategory === cat.id
? "bg-accent text-accent-foreground"
: "border border-border hover:bg-background-alt"
}`}
>
{cat.name} ({getCategoryCount(cat.id)})
</button>
))}
</div>
{/* Result count */}
<p className="mt-6 text-center text-sm text-muted">
Showing {filtered.length} of {tools.length} tools
</p>
{/* Tool grid */}
{filtered.length > 0 ? (
<div className="mt-8 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{filtered.map((tool) => {
const cat = categoryMap.get(tool.category);
const Icon = tool.icon;
return (
<div
key={tool.name}
className="tool-card flex flex-col items-center rounded-xl border border-border bg-background-alt px-4 py-6 text-center transition-all hover:shadow-md"
style={
{
"--cat-color": cat?.color,
} as React.CSSProperties
}
>
<Icon size={30} style={{ color: cat?.color }} className="mb-3 shrink-0" />
<span className="font-bold text-sm">{tool.name}</span>
<p className="mt-1.5 text-xs leading-relaxed text-muted">{tool.description}</p>
</div>
);
})}
</div>
) : (
<p className="mt-16 text-center text-muted">No tools found. Try a different search.</p>
)}
</div>
</section>
);
}
@@ -0,0 +1,63 @@
import { Lock, Server, Users } from "lucide-react";
import { FadeIn } from "./fade-in";
const features = [
{
title: "Data Sovereignty",
description:
"Your images never leave your network. No external API calls, no cloud dependencies. GDPR, HIPAA, CCPA compliant by architecture.",
icon: Lock,
gradient: "from-amber-500/10 to-orange-500/5",
},
{
title: "Enterprise Controls",
description:
"Multi-user authentication, team permissions, API key management, and audit logging for regulated environments.",
icon: Users,
gradient: "from-blue-500/10 to-indigo-500/5",
},
{
title: "Deploy Anywhere",
description:
"Docker, Kubernetes, bare metal. ARM and x86. Air-gapped networks. One container, any infrastructure.",
icon: Server,
gradient: "from-emerald-500/10 to-teal-500/5",
},
];
export function Enterprise() {
return (
<section id="enterprise" className="px-6 py-24 md:py-36">
<div className="mx-auto max-w-6xl">
<FadeIn>
<p className="text-center text-sm font-medium text-accent">Enterprise ready</p>
<h2 className="mt-2 font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
Your data never leaves your network.
</h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
Deploy on your infrastructure, behind your firewall, on your terms.
</p>
</FadeIn>
<div className="mt-16 grid gap-6 md:grid-cols-3">
{features.map((feat, i) => (
<FadeIn key={feat.title} delay={i * 0.1}>
<div
className={`group h-full rounded-2xl bg-gradient-to-br ${feat.gradient} border border-border p-8 transition-all hover:shadow-lg hover:-translate-y-0.5`}
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-background shadow-sm">
<feat.icon size={22} className="text-accent" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-nunito)] text-xl font-bold">
{feat.title}
</h3>
<p className="mt-3 leading-relaxed text-muted">{feat.description}</p>
</div>
</FadeIn>
))}
</div>
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
export function FadeIn({
children,
className,
delay = 0,
}: {
children: ReactNode;
className?: string;
delay?: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay, ease: "easeOut" }}
className={className}
>
{children}
</motion.div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Github } from "lucide-react";
const columns = [
{
title: "Product",
links: [
{ label: "Features", href: "/#features" },
{ label: "Pricing", href: "/#pricing" },
{ label: "Enterprise", href: "/contact" },
],
},
{
title: "Resources",
links: [
{ label: "Documentation", href: "https://docs.snapotter.com" },
{ label: "API Reference", href: "https://docs.snapotter.com/api" },
{
label: "Docker Hub",
href: "https://hub.docker.com/r/snapotter/snapotter",
},
],
},
{
title: "Community",
links: [
{ label: "GitHub", href: "https://github.com/ashim-hq/ashim" },
{
label: "Contributing",
href: "https://github.com/ashim-hq/ashim/blob/main/CONTRIBUTING.md",
},
{
label: "Discussions",
href: "https://github.com/ashim-hq/ashim/discussions",
},
],
},
{
title: "Legal",
links: [
{ label: "Licensing", href: "/#pricing" },
{ label: "Terms and Conditions", href: "/terms" },
{ label: "Privacy Policy", href: "/privacy" },
{ label: "FAQ", href: "/faq" },
],
},
];
export function Footer() {
return (
<footer className="border-t border-border bg-background-alt px-6 py-16">
<div className="mx-auto grid max-w-6xl gap-12 md:grid-cols-5">
<div>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-foreground text-sm font-bold">
S
</div>
<span className="text-lg font-bold">SnapOtter</span>
</div>
<p className="mt-3 text-sm text-muted">
Self-hosted image processing.
<br />
Your images, your infrastructure.
</p>
</div>
{columns.map((col) => (
<div key={col.title}>
<h4 className="text-sm font-semibold">{col.title}</h4>
<ul className="mt-4 space-y-3">
{col.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className="text-sm text-muted transition-colors hover:text-foreground"
{...(link.href.startsWith("http")
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className="mx-auto mt-12 flex max-w-6xl items-center justify-between border-t border-border pt-8">
<p className="text-sm text-muted">&copy; {new Date().getFullYear()} SnapOtter</p>
<a
href="https://github.com/ashim-hq/ashim"
target="_blank"
rel="noopener noreferrer"
className="text-muted transition-colors hover:text-foreground"
>
<Github size={20} />
</a>
</div>
</footer>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { TypingCursor } from "./typing-cursor";
const wordCloud = [
{ text: "Resize", x: 5, y: 14, size: 16, opacity: 0.15 },
{ text: "Crop", x: 28, y: 11, size: 14, opacity: 0.12 },
{ text: "Optimize", x: 48, y: 12, size: 13, opacity: 0.1 },
{ text: "Flip", x: 62, y: 10, size: 12, opacity: 0.08 },
{ text: "Rotate", x: 75, y: 14, size: 15, opacity: 0.14 },
{ text: "Sharpen", x: 88, y: 11, size: 13, opacity: 0.11 },
{ text: "Compress", x: 8, y: 22, size: 15, opacity: 0.13 },
{ text: "OCR", x: 25, y: 20, size: 18, opacity: 0.16 },
{ text: "Convert", x: 42, y: 18, size: 13, opacity: 0.1 },
{ text: "Enhance", x: 82, y: 19, size: 14, opacity: 0.12 },
{ text: "Upscale", x: 68, y: 22, size: 16, opacity: 0.15 },
{ text: "Watermark", x: 85, y: 26, size: 14, opacity: 0.11 },
{ text: "Remove BG", x: 3, y: 32, size: 14, opacity: 0.12 },
{ text: "Denoise", x: 18, y: 29, size: 12, opacity: 0.08 },
{ text: "Passport", x: 8, y: 38, size: 13, opacity: 0.1 },
{ text: "Face Blur", x: 2, y: 48, size: 13, opacity: 0.1 },
{ text: "Colorize", x: 85, y: 32, size: 14, opacity: 0.12 },
{ text: "Red Eye", x: 78, y: 28, size: 12, opacity: 0.08 },
{ text: "Restore", x: 90, y: 42, size: 14, opacity: 0.13 },
{ text: "Smart Crop", x: 82, y: 50, size: 13, opacity: 0.1 },
{ text: "Collage", x: 3, y: 58, size: 14, opacity: 0.12 },
{ text: "QR Code", x: 85, y: 58, size: 14, opacity: 0.11 },
{ text: "Stitch", x: 88, y: 68, size: 13, opacity: 0.1 },
{ text: "Border", x: 5, y: 68, size: 13, opacity: 0.1 },
{ text: "SVG", x: 3, y: 78, size: 16, opacity: 0.14 },
{ text: "Compare", x: 15, y: 73, size: 12, opacity: 0.08 },
{ text: "Palette", x: 78, y: 75, size: 13, opacity: 0.1 },
{ text: "GIF", x: 90, y: 78, size: 17, opacity: 0.15 },
{ text: "Batch", x: 6, y: 88, size: 14, opacity: 0.12 },
{ text: "Pipeline", x: 20, y: 90, size: 13, opacity: 0.09 },
{ text: "Favicon", x: 35, y: 92, size: 12, opacity: 0.08 },
{ text: "PDF", x: 22, y: 82, size: 16, opacity: 0.14 },
{ text: "Info", x: 45, y: 88, size: 12, opacity: 0.07 },
{ text: "Base64", x: 82, y: 88, size: 13, opacity: 0.09 },
{ text: "Barcode", x: 72, y: 85, size: 12, opacity: 0.08 },
{ text: "Metadata", x: 10, y: 95, size: 12, opacity: 0.07 },
{ text: "Open Source", x: 48, y: 95, size: 13, opacity: 0.1 },
{ text: "Duplicates", x: 88, y: 95, size: 12, opacity: 0.07 },
{ text: "Vectorize", x: 65, y: 92, size: 12, opacity: 0.08 },
];
function useMouseParallax(strength = 30) {
const [offset, setOffset] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
setOffset({
x: ((e.clientX - cx) / cx) * strength,
y: ((e.clientY - cy) / cy) * strength,
});
});
},
[strength],
);
useEffect(() => {
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
cancelAnimationFrame(rafRef.current);
};
}, [handleMouseMove]);
return offset;
}
export function Hero() {
const mouse = useMouseParallax(25);
return (
<section className="relative overflow-hidden px-6 pt-32 pb-24 md:pt-44 md:pb-36">
{/* Gradient mesh background - follows mouse */}
<div
className="pointer-events-none absolute inset-0 -z-10 transition-transform duration-700 ease-out"
style={{ transform: `translate(${mouse.x}px, ${mouse.y}px)` }}
>
<div className="absolute -top-[40%] -left-[20%] h-[80%] w-[60%] rounded-full bg-amber-400/20 blur-[120px]" />
<div className="absolute -top-[20%] -right-[10%] h-[70%] w-[50%] rounded-full bg-orange-300/15 blur-[100px]" />
<div className="absolute top-[20%] left-[30%] h-[60%] w-[40%] rounded-full bg-yellow-200/10 blur-[140px]" />
<div className="absolute -bottom-[30%] -right-[20%] h-[60%] w-[50%] rounded-full bg-orange-500/10 blur-[120px]" />
</div>
<div className="pointer-events-none absolute bottom-0 left-0 -z-10 h-px w-full bg-gradient-to-r from-transparent via-border to-transparent" />
{/* Word cloud background - follows mouse in opposite direction */}
<div
className="pointer-events-none absolute inset-0 -z-10 hidden transition-transform duration-1000 ease-out md:block"
style={{ transform: `translate(${mouse.x * -0.4}px, ${mouse.y * -0.4}px)` }}
>
{wordCloud.map((word) => (
<span
key={word.text}
className="absolute font-semibold text-foreground select-none"
style={{
left: `${word.x}%`,
top: `${word.y}%`,
fontSize: `${word.size}px`,
opacity: word.opacity,
}}
>
{word.text}
</span>
))}
</div>
<div className="relative mx-auto max-w-4xl text-center">
<h1 className="animate-[fadeUp_0.6s_ease-out_both] font-[family-name:var(--font-nunito)] text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
Your images. Stay yours.
</h1>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl">
The open-source, self-hosted image processing platform.
</p>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
<TypingCursor />
</p>
<div className="mt-10 animate-[fadeUp_0.6s_ease-out_0.3s_both]">
<a
href="https://github.com/ashim-hq/ashim"
target="_blank"
rel="noopener noreferrer"
className="group relative inline-flex items-center gap-2 overflow-hidden rounded-full bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500 bg-[length:200%_100%] px-10 py-4 text-base font-semibold text-white shadow-[0_0_32px_-8px] shadow-amber-500/40 transition-all duration-500 hover:bg-[position:100%_0] hover:shadow-[0_0_40px_-4px] hover:shadow-amber-500/50"
>
Get it for free
<span className="transition-transform duration-300 group-hover:translate-x-1">
&rarr;
</span>
</a>
</div>
</div>
</section>
);
}
@@ -0,0 +1,87 @@
"use client";
import { Check, Copy, Terminal } from "lucide-react";
import { useState } from "react";
import { FadeIn } from "./fade-in";
const command =
"docker run -d --name ashim -p 1349:1349 -v ashim-data:/data ghcr.io/ashim-hq/ashim:latest";
export function HowItWorks() {
const [copied, setCopied] = useState(false);
function copyCommand() {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<section id="how-it-works" className="bg-background-alt px-6 py-14 md:py-20">
<div className="mx-auto max-w-3xl text-center">
<FadeIn>
<p className="text-sm font-medium text-accent">Get started in seconds</p>
<h2 className="mt-2 font-[family-name:var(--font-nunito)] text-2xl font-bold tracking-tight md:text-3xl">
One command. That&apos;s it.
</h2>
</FadeIn>
<FadeIn delay={0.1}>
<div className="relative mx-auto mt-8 max-w-2xl">
<div className="absolute -inset-1 rounded-2xl bg-gradient-to-r from-amber-500/20 via-orange-500/10 to-amber-500/20 blur-lg" />
<button
type="button"
onClick={copyCommand}
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl border border-white/10 bg-[#151515] text-left shadow-2xl transition-all hover:border-amber-500/30"
>
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-3">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<div className="h-3 w-3 rounded-full bg-[#febc2e]" />
<div className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<div className="flex items-center gap-2 text-white/30">
<Terminal size={13} />
<span className="text-xs">Quick Start</span>
</div>
<span>
{copied ? (
<span className="flex items-center gap-1.5 text-emerald-400">
<Check size={13} />
<span className="text-xs font-medium">Copied!</span>
</span>
) : (
<span className="flex items-center gap-1.5 text-white/30 transition-colors group-hover:text-white/60">
<Copy size={13} />
<span className="text-xs">Copy</span>
</span>
)}
</span>
</div>
<div className="overflow-x-auto px-6 py-5">
<p className="whitespace-nowrap font-mono text-[13px]">
<span className="text-emerald-400">$</span>{" "}
<span className="text-white/90">{command}</span>
</p>
</div>
</button>
</div>
</FadeIn>
<FadeIn delay={0.15}>
<p className="mt-8 text-sm text-muted">
Works on Linux, macOS, and Windows. ARM and x86 supported.{" "}
<a
href="https://docs.snapotter.com"
className="font-medium text-accent hover:underline"
>
Read the docs
</a>
</p>
</FadeIn>
</div>
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { Github, Menu, Star, X } from "lucide-react";
import { useEffect, useState } from "react";
function formatStarCount(count: number): string {
if (count >= 1000) {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
return count.toString();
}
const links = [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "Docs", href: "https://docs.snapotter.com" },
{ label: "Contact", href: "/contact" },
];
export function Navbar() {
const [open, setOpen] = useState(false);
const [stars, setStars] = useState<string>("Star");
useEffect(() => {
fetch("https://api.github.com/repos/ashim-hq/ashim")
.then((res) => res.json())
.then((data) => {
if (typeof data.stargazers_count === "number") {
setStars(formatStarCount(data.stargazers_count));
}
})
.catch(() => {});
}, []);
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-background/60 backdrop-blur-xl after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-gradient-to-r after:from-transparent after:via-accent/30 after:to-transparent">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<a href="/" className="flex items-center gap-2">
<img
src="/logo.png"
alt="SnapOtter"
className="h-8 w-8"
style={{ imageRendering: "auto" }}
/>
<span className="font-[family-name:var(--font-nunito)] text-lg font-bold tracking-tight">
SnapOtter
</span>
</a>
<div className="hidden items-center gap-6 lg:flex">
{links.map((item) => (
<a
key={item.label}
href={item.href}
className="text-sm text-muted transition-colors hover:text-foreground"
{...(item.href.startsWith("http") || item.href.startsWith("mailto")
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{item.label}
</a>
))}
</div>
<div className="hidden items-center gap-3 md:flex">
<a
href="https://github.com/ashim-hq/ashim"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-background-alt"
>
<Github size={16} />
<Star size={12} className="text-accent" />
<span>Star on GitHub</span>
{stars !== "Star" && (
<span className="rounded-full bg-background-alt px-1.5 py-0.5 text-xs">{stars}</span>
)}
</a>
<a
href="/contact"
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
</a>
</div>
<button
className="text-muted lg:hidden"
onClick={() => setOpen(!open)}
aria-label="Toggle menu"
type="button"
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{open && (
<div className="border-t border-border bg-background px-6 py-4 lg:hidden">
{links.map((item) => (
<a
key={item.label}
href={item.href}
className="block py-2 text-sm text-muted"
onClick={() => setOpen(false)}
>
{item.label}
</a>
))}
<div className="mt-3 flex flex-col gap-2">
<a
href="https://github.com/ashim-hq/ashim"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 rounded-lg border border-border px-4 py-2 text-sm font-medium"
>
<Github size={16} />
<Star size={12} className="text-accent" />
Star on GitHub
</a>
<a
href="/contact"
className="block rounded-lg bg-accent px-4 py-2 text-center text-sm font-medium text-accent-foreground"
>
Book a Demo
</a>
</div>
</div>
)}
</nav>
);
}
@@ -0,0 +1,32 @@
import { Github, Star } from "lucide-react";
import { FadeIn } from "./fade-in";
export function OpenSource() {
return (
<section id="open-source" className="px-6 py-24 md:py-36">
<div className="mx-auto max-w-3xl text-center">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-3xl font-bold tracking-tight md:text-4xl">
Open source. Always.
</h2>
<p className="mt-6 text-lg leading-relaxed text-muted">
SnapOtter is AGPL-3.0 licensed. Inspect every line of code. Contribute back. Self-host
forever. No vendor lock-in, no surprise pricing changes, no rug pulls.
</p>
<div className="mt-10">
<a
href="https://github.com/ashim-hq/ashim"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-xl bg-teal px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-teal-hover"
>
<Github size={20} />
Star on GitHub
<Star size={16} className="text-accent" />
</a>
</div>
</FadeIn>
</div>
</section>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { Check } from "lucide-react";
import { FadeIn } from "./fade-in";
const freePlan = {
name: "Open Source",
price: "Free",
subtitle: "For everyone. Forever.",
features: [
"All 40+ image processing tools",
"Unlimited usage, no hidden caps",
"Full REST API with OpenAPI docs",
"Pipeline automation",
"Batch processing (unlimited files)",
"14 local AI models included",
"Self-host on any infrastructure",
"Docker, Kubernetes, bare metal",
"ARM and x86 support",
"Air-gapped and offline ready",
"Multi-user with team permissions",
"Audit logging",
"Community support via GitHub",
"All updates included",
"AGPL-3.0 licensed",
],
cta: "Get Started Free",
href: "https://github.com/ashim-hq/ashim",
external: true,
};
const enterprisePlan = {
name: "Enterprise",
price: "Custom",
subtitle: "For organizations that need more.",
features: [
"Everything in Open Source",
"Commercial license (no AGPL obligations)",
"Proprietary use in closed-source products",
"Unlimited users and devices",
"Priority email and video support",
"Dedicated deployment assistance",
"Custom integrations and workflows",
"Early access to new features and updates",
"Flexible licensing terms for your needs",
"SLA and uptime guarantees",
"Security review and compliance documentation",
"OEM and white-label options",
],
cta: "Contact Sales",
href: "/contact",
external: false,
};
export function Pricing() {
return (
<section id="pricing" className="bg-background-alt px-6 py-24 md:py-36">
<div className="mx-auto max-w-5xl">
<FadeIn>
<p className="text-center text-sm font-medium text-accent">Pricing</p>
<h2 className="mt-2 font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
Free for everyone. Enterprise when you need it.
</h2>
<p className="mx-auto mt-4 max-w-2xl text-center text-lg text-muted">
SnapOtter is fully functional and free. No trials, no feature gates, no time limits.
Enterprise licensing is available for organizations that need commercial terms.
</p>
</FadeIn>
<div className="mt-16 grid gap-8 md:grid-cols-2">
<FadeIn>
<div className="flex h-full flex-col rounded-2xl border border-border bg-background p-8">
<p className="text-sm font-medium text-accent">{freePlan.subtitle}</p>
<h3 className="mt-1 font-[family-name:var(--font-nunito)] text-2xl font-bold">
{freePlan.name}
</h3>
<p className="mt-4 text-4xl font-bold">{freePlan.price}</p>
<ul className="mt-8 flex-1 space-y-3">
{freePlan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 text-sm">
<Check size={16} className="mt-0.5 shrink-0 text-emerald-500" />
<span>{feature}</span>
</li>
))}
</ul>
<a
href={freePlan.href}
target="_blank"
rel="noopener noreferrer"
className="mt-8 block rounded-xl border-2 border-accent px-4 py-3 text-center text-sm font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-foreground"
>
{freePlan.cta} &rarr;
</a>
</div>
</FadeIn>
<FadeIn delay={0.1}>
<div className="flex h-full flex-col rounded-2xl border border-border bg-background p-8">
<p className="text-sm font-medium text-accent">{enterprisePlan.subtitle}</p>
<h3 className="mt-1 font-[family-name:var(--font-nunito)] text-2xl font-bold">
{enterprisePlan.name}
</h3>
<p className="mt-4 text-4xl font-bold">{enterprisePlan.price}</p>
<ul className="mt-8 flex-1 space-y-3">
{enterprisePlan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 text-sm">
<Check size={16} className="mt-0.5 shrink-0 text-emerald-500" />
<span>{feature}</span>
</li>
))}
</ul>
<a
href={enterprisePlan.href}
className="mt-8 block rounded-xl bg-accent px-4 py-3 text-center text-sm font-semibold text-accent-foreground transition-colors hover:bg-accent-hover"
>
{enterprisePlan.cta} &rarr;
</a>
</div>
</FadeIn>
</div>
</div>
</section>
);
}
@@ -0,0 +1,48 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useState } from "react";
const phrases = [
"No signups. No accounts.",
"No uploads to the cloud. Ever.",
"100% local processing.",
"Free. Forever.",
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"40+ image tools.",
"14 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
"Open source. Always.",
];
export function TypingCursor() {
const [index, setIndex] = useState(0);
const advance = useCallback(() => {
setIndex((i) => (i + 1) % phrases.length);
}, []);
useEffect(() => {
const timer = setInterval(advance, 3000);
return () => clearInterval(timer);
}, [advance]);
return (
<AnimatePresence mode="wait">
<motion.span
key={phrases[index]}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="text-accent"
>
{phrases[index]}
</motion.span>
</AnimatePresence>
);
}
+106
View File
@@ -0,0 +1,106 @@
import {
BadgeCheck,
CircleDollarSign,
Code,
Layers,
Plug,
ShieldCheck,
UserRoundX,
Workflow,
Zap,
} from "lucide-react";
import { FadeIn } from "./fade-in";
const benefits = [
{
title: "No Signup",
description: "Start instantly, no accounts or emails required.",
icon: UserRoundX,
gradient: "from-rose-500/10 to-pink-500/5",
},
{
title: "No Uploads",
description: "Your files stay on your server. Nothing leaves your network.",
icon: ShieldCheck,
gradient: "from-blue-500/10 to-indigo-500/5",
},
{
title: "Forever Free",
description: "All tools, no trials, no paywalls. Open source.",
icon: CircleDollarSign,
gradient: "from-emerald-500/10 to-teal-500/5",
},
{
title: "No Limits",
description: "Use as much as you want, no hidden caps.",
icon: BadgeCheck,
gradient: "from-violet-500/10 to-purple-500/5",
},
{
title: "Batch Processing",
description: "Handle unlimited images in one go.",
icon: Layers,
gradient: "from-amber-500/10 to-orange-500/5",
},
{
title: "Lightning Fast",
description: "Powered by Sharp. Process images instantly.",
icon: Zap,
gradient: "from-yellow-500/10 to-amber-500/5",
},
{
title: "Open Source",
description: "AGPL-3.0 licensed. Inspect every line of code.",
icon: Code,
gradient: "from-cyan-500/10 to-sky-500/5",
},
{
title: "REST API",
description: "Every tool accessible via HTTP. Full OpenAPI docs.",
icon: Plug,
gradient: "from-fuchsia-500/10 to-pink-500/5",
},
{
title: "Pipeline Automation",
description: "Chain tools together. Automate your workflows.",
icon: Workflow,
gradient: "from-orange-500/10 to-red-500/5",
},
];
export function WhyChoose() {
return (
<section className="bg-background-alt px-6 py-24 md:py-36">
<div className="mx-auto max-w-6xl">
<FadeIn>
<p className="text-center text-sm font-medium text-accent">Why SnapOtter</p>
<h2 className="mt-2 font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
Built different. On purpose.
</h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
No compromises on privacy, speed, or freedom.
</p>
</FadeIn>
<div className="mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{benefits.map((benefit, i) => (
<FadeIn key={benefit.title} delay={i * 0.04}>
<div
className={`group h-full rounded-2xl bg-gradient-to-br ${benefit.gradient} border border-border p-6 transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5`}
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-background shadow-sm">
<benefit.icon size={22} className="text-accent" />
</div>
<h3 className="mt-4 font-[family-name:var(--font-nunito)] text-base font-bold">
{benefit.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">{benefit.description}</p>
</div>
</FadeIn>
))}
</div>
</div>
</section>
);
}
+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
@theme {
--color-accent: #f59e0b;
--color-accent-hover: #d97706;
--color-accent-foreground: #ffffff;
--color-background: #ffffff;
--color-background-alt: #fafaf9;
--color-foreground: #0a0a0a;
--color-muted: #737373;
--color-border: #e7e5e4;
--color-dark-bg: #0c0a09;
--color-dark-fg: #fafaf9;
--color-dark-muted: #a8a29e;
--color-teal: #14b8a6;
--color-teal-hover: #0d9488;
}
/* Tool card hover border uses --cat-color set inline */
.tool-card:hover {
border-color: var(--cat-color);
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
],
"incremental": true,
"allowJs": true
},
"include": ["src/**/*", "next-env.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
+670
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
import { BentoGrid } from "@landing/components/bento-grid";
afterEach(cleanup);
function getCountText(): string {
const el = screen.getByText(/^Showing/);
return el.textContent ?? "";
}
describe("BentoGrid", () => {
it("renders the section heading", () => {
render(<BentoGrid />);
expect(screen.getByText("40+ tools. Zero cloud dependency.")).toBeDefined();
});
it("renders the search input", () => {
render(<BentoGrid />);
expect(screen.getByPlaceholderText("Search tools...")).toBeDefined();
});
it("shows all tools by default", () => {
render(<BentoGrid />);
const text = getCountText();
expect(text).toMatch(/Showing 46 of 46 tools/);
});
it("renders category filter pills including All", () => {
render(<BentoGrid />);
expect(screen.getByText((_, el) => el?.textContent === "All (46)")).toBeDefined();
expect(screen.getByText(/Essentials/)).toBeDefined();
expect(screen.getByText(/AI Tools/)).toBeDefined();
expect(screen.getByText(/Optimization/)).toBeDefined();
});
it("filters tools by search query", () => {
render(<BentoGrid />);
const input = screen.getByPlaceholderText("Search tools...");
fireEvent.change(input, { target: { value: "resize" } });
expect(screen.getByText("Resize")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing \d+ of 46 tools/);
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
});
it("filters tools by category", () => {
render(<BentoGrid />);
const aiButton = screen.getByText(/AI Tools/);
fireEvent.click(aiButton);
const text = getCountText();
expect(text).toMatch(/Showing 13 of 46 tools/);
expect(screen.getByText("Remove Background")).toBeDefined();
expect(screen.queryByText("Resize")).toBeNull();
});
it("shows empty state when search matches nothing", () => {
render(<BentoGrid />);
const input = screen.getByPlaceholderText("Search tools...");
fireEvent.change(input, { target: { value: "xyznonexistent" } });
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing 0 of 46 tools/);
});
it("combines search and category filter", () => {
render(<BentoGrid />);
const aiButton = screen.getByText(/AI Tools/);
fireEvent.click(aiButton);
const input = screen.getByPlaceholderText("Search tools...");
fireEvent.change(input, { target: { value: "background" } });
expect(screen.getByText("Remove Background")).toBeDefined();
expect(screen.queryByText("Image Upscaling")).toBeNull();
});
it("clicking All resets category filter", () => {
render(<BentoGrid />);
fireEvent.click(screen.getByText(/AI Tools/));
expect(getCountText()).toMatch(/Showing 13 of 46 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (46)"));
expect(getCountText()).toMatch(/Showing 46 of 46 tools/);
});
it("renders each tool with name and description", () => {
render(<BentoGrid />);
expect(screen.getByText("Crop")).toBeDefined();
expect(screen.getByText("Freeform crop, aspect ratio presets, shape crop")).toBeDefined();
});
it("renders all 8 category pills", () => {
render(<BentoGrid />);
const categoryNames = [
"Essentials",
"Optimization",
"Adjustments",
"AI Tools",
"Watermark & Overlay",
"Utilities",
"Layout & Composition",
"Format & Conversion",
];
for (const name of categoryNames) {
expect(screen.getByText(new RegExp(name))).toBeDefined();
}
});
});
+261
View File
@@ -0,0 +1,261 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import type React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
span: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<span {...props}>{children}</span>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const fetchMock = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ stargazers_count: 500 }),
});
vi.stubGlobal("fetch", fetchMock);
import { Enterprise } from "@landing/components/enterprise";
import { Footer } from "@landing/components/footer";
import { Hero } from "@landing/components/hero";
import { OpenSource } from "@landing/components/open-source";
import { Pricing } from "@landing/components/pricing";
import { TypingCursor } from "@landing/components/typing-cursor";
import { WhyChoose } from "@landing/components/why-choose";
afterEach(cleanup);
// ── Hero ──────────────────────────────────────────────────────────────
describe("Hero", () => {
it("renders the headline", () => {
render(<Hero />);
expect(screen.getByText("Your images. Stay yours.")).toBeDefined();
});
it("renders the subheadline", () => {
render(<Hero />);
expect(
screen.getByText("The open-source, self-hosted image processing platform."),
).toBeDefined();
});
it("renders the CTA button linking to GitHub", () => {
render(<Hero />);
const cta = screen.getByText("Get it for free");
const link = cta.closest("a");
expect(link?.getAttribute("href")).toBe("https://github.com/ashim-hq/ashim");
expect(link?.getAttribute("target")).toBe("_blank");
});
it("renders the word cloud (hidden on mobile)", () => {
render(<Hero />);
expect(screen.getByText("Resize")).toBeDefined();
expect(screen.getByText("OCR")).toBeDefined();
expect(screen.getByText("Remove BG")).toBeDefined();
});
});
// ── TypingCursor ──────────────────────────────────────────────────────
describe("TypingCursor", () => {
it("renders the first phrase", () => {
render(<TypingCursor />);
expect(screen.getByText("No signups. No accounts.")).toBeDefined();
});
it("uses a 3-second interval for rotation", () => {
vi.useFakeTimers();
render(<TypingCursor />);
const first = screen.getByText("No signups. No accounts.");
expect(first).toBeDefined();
vi.useRealTimers();
});
});
// ── WhyChoose ─────────────────────────────────────────────────────────
describe("WhyChoose", () => {
it("renders the section heading", () => {
render(<WhyChoose />);
expect(screen.getByText("Built different. On purpose.")).toBeDefined();
});
it("renders all 9 benefit cards", () => {
render(<WhyChoose />);
const titles = [
"No Signup",
"No Uploads",
"Forever Free",
"No Limits",
"Batch Processing",
"Lightning Fast",
"Open Source",
"REST API",
"Pipeline Automation",
];
for (const title of titles) {
expect(screen.getByText(title)).toBeDefined();
}
});
it("renders descriptions for each card", () => {
render(<WhyChoose />);
expect(screen.getByText("Start instantly, no accounts or emails required.")).toBeDefined();
expect(screen.getByText("Chain tools together. Automate your workflows.")).toBeDefined();
});
});
// ── Enterprise ────────────────────────────────────────────────────────
describe("Enterprise", () => {
it("renders the section heading", () => {
render(<Enterprise />);
expect(screen.getByText("Your data never leaves your network.")).toBeDefined();
});
it("renders all 3 enterprise feature cards", () => {
render(<Enterprise />);
expect(screen.getByText("Data Sovereignty")).toBeDefined();
expect(screen.getByText("Enterprise Controls")).toBeDefined();
expect(screen.getByText("Deploy Anywhere")).toBeDefined();
});
it("mentions compliance standards", () => {
render(<Enterprise />);
expect(screen.getByText(/GDPR, HIPAA, CCPA/)).toBeDefined();
});
});
// ── Pricing ───────────────────────────────────────────────────────────
describe("Pricing", () => {
it("renders the section heading", () => {
render(<Pricing />);
expect(screen.getByText(/Free for everyone/)).toBeDefined();
});
it("renders both plan names", () => {
render(<Pricing />);
expect(screen.getByText("Open Source")).toBeDefined();
expect(screen.getByText("Enterprise")).toBeDefined();
});
it("renders Free price", () => {
render(<Pricing />);
expect(screen.getByText("Free")).toBeDefined();
});
it("renders Custom price for enterprise", () => {
render(<Pricing />);
expect(screen.getByText("Custom")).toBeDefined();
});
it("renders free plan features", () => {
render(<Pricing />);
expect(screen.getByText("All 40+ image processing tools")).toBeDefined();
expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined();
expect(screen.getByText("14 local AI models included")).toBeDefined();
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
});
it("renders enterprise features", () => {
render(<Pricing />);
expect(screen.getByText("Everything in Open Source")).toBeDefined();
expect(screen.getByText("Priority email and video support")).toBeDefined();
expect(screen.getByText("OEM and white-label options")).toBeDefined();
});
it("links free plan CTA to GitHub", () => {
render(<Pricing />);
const freeButton = screen.getByText(/Get Started Free/);
expect(freeButton.closest("a")?.getAttribute("href")).toBe("https://github.com/ashim-hq/ashim");
});
it("links enterprise CTA to contact page", () => {
render(<Pricing />);
const contactButton = screen.getByText(/Contact Sales/);
expect(contactButton.closest("a")?.getAttribute("href")).toBe("/contact");
});
});
// ── OpenSource ────────────────────────────────────────────────────────
describe("OpenSource", () => {
it("renders the heading", () => {
render(<OpenSource />);
expect(screen.getByText("Open source. Always.")).toBeDefined();
});
it("renders the description mentioning AGPL-3.0", () => {
render(<OpenSource />);
expect(screen.getByText(/AGPL-3.0 licensed/)).toBeDefined();
});
it("links to GitHub", () => {
render(<OpenSource />);
const link = screen.getByText("Star on GitHub").closest("a");
expect(link?.getAttribute("href")).toBe("https://github.com/ashim-hq/ashim");
});
});
// ── Footer ────────────────────────────────────────────────────────────
describe("Footer", () => {
it("renders the brand", () => {
render(<Footer />);
expect(screen.getByText("SnapOtter")).toBeDefined();
});
it("renders all four column titles", () => {
render(<Footer />);
expect(screen.getByText("Product")).toBeDefined();
expect(screen.getByText("Resources")).toBeDefined();
expect(screen.getByText("Community")).toBeDefined();
expect(screen.getByText("Legal")).toBeDefined();
});
it("renders product links", () => {
render(<Footer />);
expect(screen.getByText("Features")).toBeDefined();
expect(screen.getByText("Pricing")).toBeDefined();
});
it("renders community links to GitHub", () => {
render(<Footer />);
const ghLink = screen.getByText("GitHub");
expect(ghLink.closest("a")?.getAttribute("href")).toBe("https://github.com/ashim-hq/ashim");
});
it("renders legal links", () => {
render(<Footer />);
expect(screen.getByText("Terms and Conditions")).toBeDefined();
expect(screen.getByText("Privacy Policy")).toBeDefined();
expect(screen.getByText("FAQ")).toBeDefined();
});
it("renders the copyright notice with current year", () => {
render(<Footer />);
const year = new Date().getFullYear();
expect(screen.getByText(new RegExp(`${year}`))).toBeDefined();
});
it("opens external links in new tab", () => {
render(<Footer />);
const docsLink = screen.getByText("Documentation").closest("a");
expect(docsLink?.getAttribute("target")).toBe("_blank");
expect(docsLink?.getAttribute("rel")).toBe("noopener noreferrer");
});
it("keeps internal links without target=_blank", () => {
render(<Footer />);
const faqLink = screen.getByText("FAQ").closest("a");
expect(faqLink?.getAttribute("target")).toBeNull();
});
});
+184
View File
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/fade-in", () => ({
FadeIn: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));
vi.mock("@/components/footer", () => ({
Footer: () => <footer data-testid="footer" />,
}));
vi.mock("@/components/navbar", () => ({
Navbar: () => <nav data-testid="navbar" />,
}));
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
import ContactPage from "@landing/app/contact/page";
beforeEach(() => {
fetchMock.mockReset();
// Default: GitHub star fetch succeeds (Navbar), form submissions configured per-test
fetchMock.mockImplementation((url: string) => {
if (url.includes("github.com")) {
return Promise.resolve({ json: () => Promise.resolve({ stargazers_count: 100 }) });
}
return Promise.resolve({ ok: true });
});
});
afterEach(cleanup);
describe("ContactPage", () => {
it("renders the page heading", () => {
render(<ContactPage />);
expect(screen.getByText("Get in touch")).toBeDefined();
});
it("renders all three benefit cards", () => {
render(<ContactPage />);
expect(screen.getByText("Live Demo")).toBeDefined();
expect(screen.getByText("Deployment Support")).toBeDefined();
// "Enterprise Licensing" appears as both a card title and a dropdown option
expect(screen.getAllByText("Enterprise Licensing").length).toBeGreaterThanOrEqual(2);
});
it("renders all form fields", () => {
render(<ContactPage />);
expect(screen.getByLabelText(/Name/)).toBeDefined();
expect(screen.getByLabelText(/Email/)).toBeDefined();
expect(screen.getByLabelText(/Company/)).toBeDefined();
expect(screen.getByLabelText(/Subject/)).toBeDefined();
expect(screen.getByLabelText(/Message/)).toBeDefined();
});
it("renders the submit button", () => {
render(<ContactPage />);
expect(screen.getByText("Send Message")).toBeDefined();
});
it("renders subject dropdown with all options", () => {
render(<ContactPage />);
const select = screen.getByLabelText(/Subject/) as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.value);
expect(options).toEqual([
"Book a Demo",
"Enterprise Licensing",
"Deployment Help",
"General Inquiry",
]);
});
it("defaults subject to Book a Demo", () => {
render(<ContactPage />);
const select = screen.getByLabelText(/Subject/) as HTMLSelectElement;
expect(select.value).toBe("Book a Demo");
});
it("shows success state after form submission", async () => {
render(<ContactPage />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: "Test User" } });
fireEvent.change(screen.getByLabelText(/Email/), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByLabelText(/Company/), { target: { value: "TestCo" } });
fireEvent.change(screen.getByLabelText(/Message/), { target: { value: "Hello" } });
fireEvent.submit(screen.getByText("Send Message").closest("form")!);
await waitFor(() => {
expect(screen.getByText("Message sent!")).toBeDefined();
});
expect(screen.getByText(/Thanks for reaching out/)).toBeDefined();
});
it("shows error message on failed submission", async () => {
fetchMock.mockImplementation((url: string) => {
if (url.includes("github.com")) {
return Promise.resolve({ json: () => Promise.resolve({ stargazers_count: 100 }) });
}
return Promise.resolve({ ok: false, status: 500 });
});
render(<ContactPage />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: "Test User" } });
fireEvent.change(screen.getByLabelText(/Email/), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByLabelText(/Company/), { target: { value: "TestCo" } });
fireEvent.change(screen.getByLabelText(/Message/), { target: { value: "Hello" } });
fireEvent.submit(screen.getByText("Send Message").closest("form")!);
await waitFor(() => {
expect(screen.getByText(/Something went wrong/)).toBeDefined();
});
});
it("shows error message on network failure", async () => {
fetchMock.mockImplementation((url: string) => {
if (url.includes("github.com")) {
return Promise.resolve({ json: () => Promise.resolve({ stargazers_count: 100 }) });
}
return Promise.reject(new Error("Network error"));
});
render(<ContactPage />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: "Test User" } });
fireEvent.change(screen.getByLabelText(/Email/), { target: { value: "test@example.com" } });
fireEvent.change(screen.getByLabelText(/Company/), { target: { value: "TestCo" } });
fireEvent.change(screen.getByLabelText(/Message/), { target: { value: "Hello" } });
fireEvent.submit(screen.getByText("Send Message").closest("form")!);
await waitFor(() => {
expect(screen.getByText(/Network error/)).toBeDefined();
});
});
it("allows sending another message after success", async () => {
render(<ContactPage />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: "Test" } });
fireEvent.change(screen.getByLabelText(/Email/), { target: { value: "t@t.com" } });
fireEvent.change(screen.getByLabelText(/Company/), { target: { value: "Co" } });
fireEvent.change(screen.getByLabelText(/Message/), { target: { value: "Hi" } });
fireEvent.submit(screen.getByText("Send Message").closest("form")!);
await waitFor(() => {
expect(screen.getByText("Message sent!")).toBeDefined();
});
fireEvent.click(screen.getByText("Send another message"));
expect(screen.getByText("Send Message")).toBeDefined();
});
it("submits to Formspree URL", async () => {
render(<ContactPage />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: "Test" } });
fireEvent.change(screen.getByLabelText(/Email/), { target: { value: "t@t.com" } });
fireEvent.change(screen.getByLabelText(/Company/), { target: { value: "Co" } });
fireEvent.change(screen.getByLabelText(/Message/), { target: { value: "Hi" } });
fireEvent.submit(screen.getByText("Send Message").closest("form")!);
await waitFor(() => {
const formspreeCall = fetchMock.mock.calls.find(
(c: string[]) => typeof c[0] === "string" && c[0].includes("formspree.io"),
);
expect(formspreeCall).toBeDefined();
expect(formspreeCall![0]).toBe("https://formspree.io/f/mykllwek");
});
});
it("renders the email fallback", () => {
render(<ContactPage />);
const emailLink = screen.getByText("contact@snapotter.com");
expect(emailLink.closest("a")?.getAttribute("href")).toBe("mailto:contact@snapotter.com");
});
});
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
// The FAQ page source imports from @/components/* which vitest resolves through
// the @ alias to apps/web/src/. These components only exist in the landing app,
// so we mock them at the resolved path vitest will look for.
vi.mock("@/components/fade-in", () => ({
FadeIn: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));
vi.mock("@/components/footer", () => ({
Footer: () => <footer data-testid="footer" />,
}));
vi.mock("@/components/navbar", () => ({
Navbar: () => <nav data-testid="navbar" />,
}));
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const fetchMock = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ stargazers_count: 100 }),
});
vi.stubGlobal("fetch", fetchMock);
import FaqPage from "@landing/app/faq/page";
afterEach(cleanup);
describe("FaqPage", () => {
it("renders the page heading", () => {
render(<FaqPage />);
expect(screen.getByText("Frequently Asked Questions")).toBeDefined();
});
it("renders the subtitle", () => {
render(<FaqPage />);
expect(screen.getByText("Everything you need to know about SnapOtter.")).toBeDefined();
});
it("renders all 8 FAQ questions", () => {
render(<FaqPage />);
expect(screen.getByText("Are my files safe and private?")).toBeDefined();
expect(screen.getByText("Is SnapOtter really free?")).toBeDefined();
expect(screen.getByText("Do I need an internet connection?")).toBeDefined();
expect(screen.getByText("Are there any file size or usage limitations?")).toBeDefined();
expect(screen.getByText("What AI features are included?")).toBeDefined();
expect(screen.getByText("Does SnapOtter collect any analytics?")).toBeDefined();
expect(screen.getByText("Can I use SnapOtter in my company?")).toBeDefined();
expect(screen.getByText("How do I update SnapOtter?")).toBeDefined();
});
it("does not show answers by default", () => {
render(<FaqPage />);
expect(screen.queryByText(/All processing happens on your own server/)).toBeNull();
expect(screen.queryByText(/open source under AGPL-3.0/)).toBeNull();
});
it("expands an answer when the question is clicked", () => {
render(<FaqPage />);
const question = screen.getByText("Are my files safe and private?");
fireEvent.click(question);
expect(screen.getByText(/All processing happens on your own server/)).toBeDefined();
});
it("collapses an answer when the question is clicked again", () => {
render(<FaqPage />);
const question = screen.getByText("Are my files safe and private?");
fireEvent.click(question);
expect(screen.getByText(/All processing happens on your own server/)).toBeDefined();
fireEvent.click(question);
expect(screen.queryByText(/All processing happens on your own server/)).toBeNull();
});
it("can have multiple FAQs open at the same time", () => {
render(<FaqPage />);
fireEvent.click(screen.getByText("Are my files safe and private?"));
fireEvent.click(screen.getByText("Is SnapOtter really free?"));
expect(screen.getByText(/All processing happens on your own server/)).toBeDefined();
expect(screen.getByText(/open source under AGPL-3.0/)).toBeDefined();
});
it("renders the Navbar and Footer stubs", () => {
render(<FaqPage />);
expect(screen.getByTestId("navbar")).toBeDefined();
expect(screen.getByTestId("footer")).toBeDefined();
});
});
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
});
import { HowItWorks } from "@landing/components/how-it-works";
beforeEach(() => {
writeTextMock.mockClear();
});
afterEach(cleanup);
const DOCKER_COMMAND =
"docker run -d --name ashim -p 1349:1349 -v ashim-data:/data ghcr.io/ashim-hq/ashim:latest";
describe("HowItWorks", () => {
it("renders the section heading", () => {
render(<HowItWorks />);
expect(screen.getByText(/One command/)).toBeDefined();
});
it("renders the subtitle", () => {
render(<HowItWorks />);
expect(screen.getByText("Get started in seconds")).toBeDefined();
});
it("displays the Docker command", () => {
render(<HowItWorks />);
expect(screen.getByText(DOCKER_COMMAND)).toBeDefined();
});
it("displays the dollar sign prompt", () => {
render(<HowItWorks />);
expect(screen.getByText("$")).toBeDefined();
});
it("shows Copy label before clicking", () => {
render(<HowItWorks />);
expect(screen.getByText("Copy")).toBeDefined();
});
it("copies command to clipboard on click", async () => {
render(<HowItWorks />);
const button = screen.getByText(DOCKER_COMMAND).closest("button")!;
fireEvent.click(button);
expect(writeTextMock).toHaveBeenCalledWith(DOCKER_COMMAND);
});
it("shows Copied! feedback after clicking", async () => {
render(<HowItWorks />);
const button = screen.getByText(DOCKER_COMMAND).closest("button")!;
fireEvent.click(button);
expect(screen.getByText("Copied!")).toBeDefined();
});
it("sets a timeout to revert copy state", () => {
vi.useFakeTimers();
const spy = vi.spyOn(globalThis, "setTimeout");
render(<HowItWorks />);
const button = screen.getByText(DOCKER_COMMAND).closest("button")!;
fireEvent.click(button);
expect(spy).toHaveBeenCalledWith(expect.any(Function), 2000);
spy.mockRestore();
vi.useRealTimers();
});
it("renders the terminal window chrome", () => {
render(<HowItWorks />);
expect(screen.getByText("Quick Start")).toBeDefined();
});
it("links to documentation", () => {
render(<HowItWorks />);
const docsLink = screen.getByText("Read the docs");
expect(docsLink.closest("a")?.getAttribute("href")).toBe("https://docs.snapotter.com");
});
it("mentions platform support", () => {
render(<HowItWorks />);
expect(screen.getByText(/Linux, macOS, and Windows/)).toBeDefined();
});
});
+127
View File
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
import { Navbar } from "@landing/components/navbar";
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({
json: () => Promise.resolve({ stargazers_count: 1234 }),
});
});
afterEach(cleanup);
describe("Navbar", () => {
it("renders the brand name", () => {
render(<Navbar />);
expect(screen.getByText("SnapOtter")).toBeDefined();
});
it("renders navigation links", () => {
render(<Navbar />);
expect(screen.getAllByText("Features").length).toBeGreaterThan(0);
expect(screen.getAllByText("Pricing").length).toBeGreaterThan(0);
expect(screen.getAllByText("Docs").length).toBeGreaterThan(0);
expect(screen.getAllByText("Contact").length).toBeGreaterThan(0);
});
it("renders Book a Demo CTA", () => {
render(<Navbar />);
const ctas = screen.getAllByText("Book a Demo");
expect(ctas.length).toBeGreaterThan(0);
});
it("fetches GitHub star count on mount", async () => {
render(<Navbar />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("https://api.github.com/repos/ashim-hq/ashim");
});
});
it("displays formatted star count after fetch", async () => {
render(<Navbar />);
await waitFor(() => {
expect(screen.getByText("1.2k")).toBeDefined();
});
});
it("formats star count correctly for exact thousands", async () => {
fetchMock.mockResolvedValue({
json: () => Promise.resolve({ stargazers_count: 2000 }),
});
render(<Navbar />);
await waitFor(() => {
expect(screen.getByText("2k")).toBeDefined();
});
});
it("shows raw count for numbers under 1000", async () => {
fetchMock.mockResolvedValue({
json: () => Promise.resolve({ stargazers_count: 456 }),
});
render(<Navbar />);
await waitFor(() => {
expect(screen.getByText("456")).toBeDefined();
});
});
it("handles star fetch failure gracefully", async () => {
fetchMock.mockRejectedValue(new Error("Network error"));
render(<Navbar />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
// Star count should not appear - still shows default "Star" text
expect(screen.getAllByText("Star on GitHub").length).toBeGreaterThan(0);
});
it("toggles mobile menu on hamburger click", () => {
render(<Navbar />);
const toggleButton = screen.getByLabelText("Toggle menu");
expect(screen.queryByText("Book a Demo")).toBeDefined();
fireEvent.click(toggleButton);
const mobileLinks = screen.getAllByText("Features");
expect(mobileLinks.length).toBeGreaterThanOrEqual(2);
});
it("closes mobile menu when a link is clicked", () => {
render(<Navbar />);
fireEvent.click(screen.getByLabelText("Toggle menu"));
const mobileFeatures = screen.getAllByText("Features");
fireEvent.click(mobileFeatures[mobileFeatures.length - 1]);
// After clicking, mobile menu should close (we can't easily test DOM removal
// without checking state, but the onClick handler calls setOpen(false))
});
it("links Docs to external URL with target=_blank", () => {
render(<Navbar />);
const docsLinks = screen.getAllByText("Docs");
const externalDoc = docsLinks.find(
(el) => el.closest("a")?.getAttribute("target") === "_blank",
);
expect(externalDoc).toBeDefined();
expect(externalDoc?.closest("a")?.getAttribute("href")).toBe("https://docs.snapotter.com");
});
it("links GitHub button to correct repo", () => {
render(<Navbar />);
const githubLinks = screen.getAllByText("Star on GitHub");
const link = githubLinks[0].closest("a");
expect(link?.getAttribute("href")).toBe("https://github.com/ashim-hq/ashim");
expect(link?.getAttribute("target")).toBe("_blank");
});
});
+12 -4
View File
@@ -9,10 +9,16 @@ const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules.
const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
// Resolve landing-workspace packages.
const landingNodeModules = path.resolve(__dirname, "apps/landing/node_modules");
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
export default defineConfig({
esbuild: {
jsx: "automatic",
},
test: {
globals: true,
testTimeout: 30_000,
@@ -71,11 +77,15 @@ export default defineConfig({
},
resolve: {
alias: {
"@landing": path.resolve(__dirname, "apps/landing/src"),
// Landing page components that don't exist in web but are imported via @/
"@/components/fade-in": path.resolve(__dirname, "apps/landing/src/components/fade-in"),
"@/components/footer": path.resolve(__dirname, "apps/landing/src/components/footer"),
"@/components/navbar": path.resolve(__dirname, "apps/landing/src/components/navbar"),
"@": path.resolve(__dirname, "apps/web/src"),
"framer-motion": path.join(landingNodeModules, "framer-motion"),
"@ashim/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"),
"@ashim/shared": path.resolve(__dirname, "packages/shared/src/index.ts"),
// Map api-only dependencies so integration tests (and transitive imports
// from apps/api/src) can resolve them from the root vitest runner.
fastify: path.join(apiNodeModules, "fastify"),
"@fastify/cors": path.join(apiNodeModules, "@fastify/cors"),
"@fastify/multipart": path.join(apiNodeModules, "@fastify/multipart"),
@@ -93,8 +103,6 @@ export default defineConfig({
jsqr: path.join(apiNodeModules, "jsqr"),
pdfkit: path.join(apiNodeModules, "pdfkit"),
sharp: path.join(apiNodeModules, "sharp"),
// Map web-only dependencies so component tests can resolve them
// from the root vitest runner.
react: path.join(webNodeModules, "react"),
"react-dom": path.join(webNodeModules, "react-dom"),
zustand: path.join(webNodeModules, "zustand"),