mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: pipeline templates, analytics opt-out, 83 conversion presets, positioning + e2e modernization
Lands five integrated branches: pipeline templates (#355), analytics opt-out (#354), 83 conversion presets bringing the catalog to 240 tools (#356), self-hosted positioning (#353), and e2e modernization (#351). Integration fixes: aligned stale web analytics tests with the opt-out/allow-list model, closed 3 CodeQL incomplete-sanitization alerts in the i18n generator, resolved settings/index/docs/format-matrix conflicts, and corrected tool counts to 240.
This commit is contained in:
@@ -19,6 +19,7 @@ const columns = [
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Self-Hosted", href: "/#features" },
|
||||
{ label: "Open Source", href: "/#open-source" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -32,8 +32,8 @@ const trustBadges = [
|
||||
|
||||
<!-- Headline: one sentence, rotating modality + fixed privacy promise -->
|
||||
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s;">
|
||||
Every <span class="word-rotator" aria-hidden="true"><span class="word-rotator-track"><span>file</span><span>image</span><span>video</span><span>audio</span><span>PDF</span><span>data</span><span>file</span></span></span><span class="sr-only">file</span> tool you need.
|
||||
<span class="mt-3 block bg-gradient-to-r from-primary to-primary-light bg-clip-text text-2xl font-bold text-transparent sm:text-3xl md:text-4xl">Nothing leaves your network.</span>
|
||||
Every <span class="word-rotator" aria-hidden="true"><span class="word-rotator-track"><span>file</span><span>image</span><span>video</span><span>audio</span><span>PDF</span><span>files</span><span>file</span></span></span><span class="sr-only">file</span> tool you need.
|
||||
<span class="mt-3 block bg-gradient-to-r from-primary to-primary-light bg-clip-text text-2xl font-bold text-transparent sm:text-3xl md:text-4xl">Your files never leave your network.</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle: one calm supporting line -->
|
||||
|
||||
@@ -53,6 +53,7 @@ function renderIcon(iconName: string): string {
|
||||
data-name={tool.name.toLowerCase()}
|
||||
data-desc={tool.description.toLowerCase()}
|
||||
data-mod={(mod?.label || "").toLowerCase()}
|
||||
data-keywords={(tool.keywords ?? []).join(" ")}
|
||||
hidden
|
||||
>
|
||||
<a
|
||||
@@ -88,63 +89,59 @@ function renderIcon(iconName: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var input = document.getElementById("hero-tool-search");
|
||||
var box = document.getElementById("hero-search-results");
|
||||
var empty = document.getElementById("hero-search-empty");
|
||||
var items = Array.prototype.slice.call(
|
||||
document.querySelectorAll("#hero-search-list .hero-result"),
|
||||
);
|
||||
var MAX = 7;
|
||||
var firstHref = null;
|
||||
<script>
|
||||
import { matchTool } from "../lib/tool-search";
|
||||
|
||||
function close() {
|
||||
const input = document.getElementById("hero-tool-search") as HTMLInputElement | null;
|
||||
const box = document.getElementById("hero-search-results");
|
||||
const empty = document.getElementById("hero-search-empty");
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLLIElement>("#hero-search-list .hero-result"),
|
||||
);
|
||||
const MAX = 7;
|
||||
let firstHref: string | null = null;
|
||||
|
||||
if (input && box && empty) {
|
||||
const close = () => {
|
||||
box.classList.add("hidden");
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
};
|
||||
|
||||
function open() {
|
||||
const open = () => {
|
||||
box.classList.remove("hidden");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
};
|
||||
|
||||
function run() {
|
||||
var q = input.value.toLowerCase().trim();
|
||||
const run = () => {
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
var tokens = q.split(/\s+/);
|
||||
var shown = 0;
|
||||
let shown = 0;
|
||||
firstHref = null;
|
||||
items.forEach(function (li) {
|
||||
var hay =
|
||||
(li.getAttribute("data-name") || "") +
|
||||
" " +
|
||||
(li.getAttribute("data-desc") || "") +
|
||||
" " +
|
||||
(li.getAttribute("data-mod") || "");
|
||||
var hit = tokens.every(function (t) {
|
||||
return hay.indexOf(t) !== -1;
|
||||
});
|
||||
for (const li of items) {
|
||||
const hay =
|
||||
`${li.getAttribute("data-name") || ""} ${li.getAttribute("data-desc") || ""} ` +
|
||||
`${li.getAttribute("data-mod") || ""} ${li.getAttribute("data-keywords") || ""}`;
|
||||
const hit = matchTool(q, hay);
|
||||
if (hit && shown < MAX) {
|
||||
li.hidden = false;
|
||||
shown++;
|
||||
if (!firstHref) firstHref = li.querySelector("a").getAttribute("href");
|
||||
if (!firstHref) firstHref = li.querySelector("a")?.getAttribute("href") ?? null;
|
||||
} else {
|
||||
li.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
empty.classList.toggle("hidden", shown !== 0);
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("input", run);
|
||||
input.addEventListener("focus", function () {
|
||||
input.addEventListener("focus", () => {
|
||||
if (input.value.trim()) run();
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && firstHref) {
|
||||
window.location.href = firstHref;
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -152,8 +149,8 @@ function renderIcon(iconName: string): string {
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!box.contains(e.target) && e.target !== input) close();
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!box.contains(e.target as Node) && e.target !== input) close();
|
||||
});
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@ const links = [
|
||||
{ label: "Features", href: "/#features" },
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Docs", href: "https://docs.snapotter.com", external: true },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ const { display: pullsDisplay } = await getImagePulls();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: "157",
|
||||
value: "240",
|
||||
label: "Processing Tools",
|
||||
sublabel: "Across 5 file modalities",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// SEO comparison pages: "open-source alternative to <X>" content.
|
||||
// Each entry renders a static page at /alternatives/<slug> plus a card on the hub.
|
||||
// Keep competitor claims general and defensible; the differentiator is self-hosting
|
||||
// plus breadth across all five modalities, not point-by-point feature parity.
|
||||
|
||||
export interface ComparisonRow {
|
||||
feature: string;
|
||||
snapotter: string;
|
||||
competitor: string;
|
||||
/** true when SnapOtter has the advantage on this row (drives the check styling) */
|
||||
snapotterWins: boolean;
|
||||
}
|
||||
|
||||
export interface AltFaq {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
export interface Alternative {
|
||||
slug: string;
|
||||
/** Competitor brand name, e.g. "Smallpdf" */
|
||||
competitor: string;
|
||||
/** Short category label for cards, e.g. "PDF tools" */
|
||||
category: string;
|
||||
/** SEO <title>, written as the phrase people search */
|
||||
pageTitle: string;
|
||||
/** On-page H1 */
|
||||
h1: string;
|
||||
/** Meta description */
|
||||
metaDescription: string;
|
||||
/** One or two sentence lede under the H1 */
|
||||
intro: string;
|
||||
/** What SnapOtter brings beyond this competitor's single lane */
|
||||
breadth: string;
|
||||
/** true when the competitor is itself open-source / self-hostable (shifts framing to breadth) */
|
||||
competitorOpenSource: boolean;
|
||||
rows: ComparisonRow[];
|
||||
faqs: AltFaq[];
|
||||
}
|
||||
|
||||
const cloudRows = (category: string, pricing: string, beyond: string): ComparisonRow[] => [
|
||||
{
|
||||
feature: "Where your files go",
|
||||
snapotter: "Your own server",
|
||||
competitor: "Their cloud",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
snapotter: "Free, open source (AGPLv3)",
|
||||
competitor: pricing,
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "File-size limits",
|
||||
snapotter: "Bounded by your hardware",
|
||||
competitor: "Capped by plan",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Works offline / air-gapped",
|
||||
snapotter: "Yes",
|
||||
competitor: "No, needs their servers",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: `Beyond ${category}`,
|
||||
snapotter: "Image, video, audio, PDF, and files",
|
||||
competitor: beyond,
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "REST API and pipelines",
|
||||
snapotter: "Built in, self-hosted",
|
||||
competitor: "API on paid plans",
|
||||
snapotterWins: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const ALTERNATIVES: Alternative[] = [
|
||||
{
|
||||
slug: "smallpdf",
|
||||
competitor: "Smallpdf",
|
||||
category: "PDF tools",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to Smallpdf",
|
||||
h1: "The open-source, self-hosted alternative to Smallpdf",
|
||||
metaDescription:
|
||||
"Smallpdf uploads your documents to its servers. SnapOtter runs the same PDF tools on yours. 40 PDF tools plus 200 more for image, video, audio, and files. Open source, AGPLv3.",
|
||||
intro:
|
||||
"Smallpdf is a cloud PDF suite, so every file you touch goes up to its servers. SnapOtter runs the same kinds of tools on hardware you own, and the file never leaves it.",
|
||||
breadth:
|
||||
"Smallpdf does PDFs. SnapOtter ships 40 PDF tools (merge, split, compress, convert, redact, OCR, and more) and another 200 across image, video, audio, and files, so one stack covers what you'd otherwise spread across several accounts.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with daily limits", "PDF only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a free, self-hosted alternative to Smallpdf?",
|
||||
a: "Yes. SnapOtter is open source under AGPLv3 and runs on your own server with Docker. It covers merge, split, compress, convert, protect, redact, watermark, page numbers, and OCR, with no per-file fees.",
|
||||
},
|
||||
{
|
||||
q: "Do my documents get uploaded anywhere?",
|
||||
a: "No. SnapOtter processes files on the machine you run it on. Nothing is sent to a third-party PDF service, which is the whole point of self-hosting it.",
|
||||
},
|
||||
{
|
||||
q: "Does it do more than PDFs?",
|
||||
a: "Yes. Beyond its 40 PDF tools, SnapOtter handles image, video, audio, and file tasks too, 240 tools in one stack.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "ilovepdf",
|
||||
competitor: "iLovePDF",
|
||||
category: "PDF tools",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to iLovePDF",
|
||||
h1: "The open-source, self-hosted alternative to iLovePDF",
|
||||
metaDescription:
|
||||
"iLovePDF processes your files in the cloud. SnapOtter runs PDF merge, split, compress, convert, and OCR on your own server. 240 tools across five file types. Open source, AGPLv3.",
|
||||
intro:
|
||||
"iLovePDF is a hosted PDF service, which means your documents are uploaded to process them. SnapOtter gives you the same toolset to run yourself, with the file staying on your server.",
|
||||
breadth:
|
||||
"iLovePDF stays inside PDFs. SnapOtter pairs 40 PDF tools with image, video, audio, and file tools, so the same deployment handles a contract, a screen recording, and a podcast edit.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("PDFs", "Subscription, free tier with limits", "PDF only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "What's a self-hosted alternative to iLovePDF?",
|
||||
a: "SnapOtter. It's open source (AGPLv3), deploys with Docker, and gives you merge, split, compress, convert, protect, OCR, and the rest on your own hardware.",
|
||||
},
|
||||
{
|
||||
q: "Is SnapOtter free?",
|
||||
a: "The full open-source edition is free forever. A paid enterprise tier adds SSO, audit logging, and similar controls, but every file tool is in the free edition.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "tinypng",
|
||||
competitor: "TinyPNG",
|
||||
category: "Image compression",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to TinyPNG",
|
||||
h1: "The open-source, self-hosted alternative to TinyPNG",
|
||||
metaDescription:
|
||||
"TinyPNG compresses images in the cloud with a monthly quota. SnapOtter compresses PNG, JPEG, WebP, and AVIF on your own server, with no quota and no upload. Open source, AGPLv3.",
|
||||
intro:
|
||||
"TinyPNG is a cloud image compressor with a monthly free quota and an upload step. SnapOtter compresses images on your own hardware, with no quota and nothing leaving your network.",
|
||||
breadth:
|
||||
"TinyPNG compresses images. SnapOtter compresses across PNG, JPEG, WebP, and AVIF, and then keeps going: resize, crop, convert between 14 output formats, watermark, plus 60+ other image tools and full video, audio, PDF, and file support.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("image compression", "Monthly free quota, then paid", "Image compression only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a self-hosted TinyPNG alternative with no quota?",
|
||||
a: "Yes. SnapOtter runs image compression locally, so the only limit is your hardware. There's no per-month cap and no API key to a third party.",
|
||||
},
|
||||
{
|
||||
q: "Which formats can it compress?",
|
||||
a: "PNG, JPEG, WebP, and AVIF, with conversion between formats. SnapOtter reads 55+ input formats including 23 camera RAW formats.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "cloudconvert",
|
||||
competitor: "CloudConvert",
|
||||
category: "File conversion",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to CloudConvert",
|
||||
h1: "The open-source, self-hosted alternative to CloudConvert",
|
||||
metaDescription:
|
||||
"CloudConvert converts files in the cloud, billed per conversion. SnapOtter converts image, video, audio, PDF, and data formats on your own server. Open source, AGPLv3, no upload.",
|
||||
intro:
|
||||
"CloudConvert is a hosted converter, so files are uploaded and conversions are metered. SnapOtter converts across every modality on hardware you own, with no metering and no upload.",
|
||||
breadth:
|
||||
"CloudConvert converts. SnapOtter converts too, across image, video, audio, PDF, and data formats, and then adds compression, editing, OCR, transcription, and pipelines, so conversion is one of 240 things it does.",
|
||||
competitorOpenSource: false,
|
||||
rows: cloudRows("file conversion", "Pay per conversion / minutes", "Conversion only"),
|
||||
faqs: [
|
||||
{
|
||||
q: "Can I self-host a CloudConvert alternative?",
|
||||
a: "Yes. SnapOtter is open source and runs on Docker. It converts image, video, audio, PDF, and data formats locally, with no per-conversion billing.",
|
||||
},
|
||||
{
|
||||
q: "Does it convert video and audio too?",
|
||||
a: "Yes. SnapOtter uses FFmpeg under the hood for video and audio, alongside Sharp for images and qpdf and LibreOffice for documents.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "otter-ai",
|
||||
competitor: "Otter.ai",
|
||||
category: "Transcription",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to Otter.ai",
|
||||
h1: "The open-source, self-hosted alternative to Otter.ai",
|
||||
metaDescription:
|
||||
"Otter.ai transcribes audio in the cloud. SnapOtter transcribes on your own GPU or CPU with local Whisper models. No upload, no per-minute fees. Open source, AGPLv3.",
|
||||
intro:
|
||||
"Otter.ai uploads your recordings to transcribe them in the cloud. SnapOtter runs speech-to-text on your own hardware with local models, so sensitive recordings never leave your network.",
|
||||
breadth:
|
||||
"Otter.ai transcribes. SnapOtter transcribes locally and also handles the rest of an audio workflow (trim, normalize, noise reduction, format conversion) plus video subtitles, image, PDF, and file tools.",
|
||||
competitorOpenSource: false,
|
||||
rows: [
|
||||
{
|
||||
feature: "Where your audio goes",
|
||||
snapotter: "Your own server / GPU",
|
||||
competitor: "Their cloud",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
snapotter: "Free, open source (AGPLv3)",
|
||||
competitor: "Subscription, per-minute limits",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Transcription engine",
|
||||
snapotter: "Local Whisper models, on your hardware",
|
||||
competitor: "Cloud service",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Works offline / air-gapped",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Beyond transcription",
|
||||
snapotter: "Audio editing, video, image, PDF, files",
|
||||
competitor: "Notes and transcription",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a self-hosted Otter.ai alternative?",
|
||||
a: "Yes. SnapOtter runs transcription locally with Whisper models on your own GPU or CPU. No audio is uploaded to a third party.",
|
||||
},
|
||||
{
|
||||
q: "Do I need a GPU?",
|
||||
a: "A GPU is faster, but transcription also runs on CPU. The models install on demand the first time you use the feature.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "stirling-pdf",
|
||||
competitor: "Stirling-PDF",
|
||||
category: "Self-hosted PDF",
|
||||
pageTitle: "SnapOtter vs Stirling-PDF: Self-Hosted File Tools Compared",
|
||||
h1: "SnapOtter vs Stirling-PDF",
|
||||
metaDescription:
|
||||
"Stirling-PDF is a great self-hosted PDF toolkit. SnapOtter covers PDFs too, plus image, video, audio, and file tools in one stack. Both open source. Here's how they compare.",
|
||||
intro:
|
||||
"Stirling-PDF and SnapOtter are both self-hosted and open source, so files stay on your server either way. The difference is scope: Stirling-PDF focuses on PDFs, SnapOtter spans all five file types.",
|
||||
breadth:
|
||||
"If you only ever touch PDFs, Stirling-PDF is excellent and worth a look. If you also resize images, convert video, transcribe audio, or batch files, SnapOtter handles all of it in one deployment instead of running several tools side by side.",
|
||||
competitorOpenSource: true,
|
||||
rows: [
|
||||
{
|
||||
feature: "Self-hosted",
|
||||
snapotter: "Yes",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Modalities covered",
|
||||
snapotter: "Image, video, audio, PDF, files",
|
||||
competitor: "PDF only",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
competitor: "50+ PDF tools",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Local AI (OCR, transcription, upscaling)",
|
||||
snapotter: "Yes, on-demand bundles",
|
||||
competitor: "OCR",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Layer-based image editor",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Pipelines and batch processing",
|
||||
snapotter: "Yes",
|
||||
competitor: "Pipelines",
|
||||
snapotterWins: false,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Is SnapOtter a replacement for Stirling-PDF?",
|
||||
a: "It can be, if you want one stack for more than PDFs. SnapOtter's PDF coverage is broad, and it adds image, video, audio, and file tools. For a PDF-only setup, Stirling-PDF is a solid, focused choice.",
|
||||
},
|
||||
{
|
||||
q: "Are both free and open source?",
|
||||
a: "Yes. Both are open source and self-hostable. SnapOtter is AGPLv3 and adds an optional paid enterprise tier for SSO and audit features.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "convertx",
|
||||
competitor: "ConvertX",
|
||||
category: "Self-hosted conversion",
|
||||
pageTitle: "SnapOtter vs ConvertX: Self-Hosted File Tools Compared",
|
||||
h1: "SnapOtter vs ConvertX",
|
||||
metaDescription:
|
||||
"ConvertX is a self-hosted file converter for 1000+ formats. SnapOtter converts too, and adds editing, compression, OCR, and transcription across five modalities. Both open source.",
|
||||
intro:
|
||||
"ConvertX and SnapOtter are both self-hosted and open source. ConvertX is built around format conversion; SnapOtter does conversion plus the rest of a file workflow.",
|
||||
breadth:
|
||||
"ConvertX is a focused converter and handles a huge list of formats. SnapOtter converts as well, then adds compression, editing, redaction, OCR, transcription, and pipelines, so it's a fit when conversion is one step among many.",
|
||||
competitorOpenSource: true,
|
||||
rows: [
|
||||
{
|
||||
feature: "Self-hosted",
|
||||
snapotter: "Yes",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
snapotter: "Yes, AGPLv3",
|
||||
competitor: "Yes",
|
||||
snapotterWins: false,
|
||||
},
|
||||
{
|
||||
feature: "Primary focus",
|
||||
snapotter: "Full file workflow across five types",
|
||||
competitor: "Format conversion",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Beyond conversion",
|
||||
snapotter: "Compress, edit, redact, OCR, transcribe",
|
||||
competitor: "Conversion",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "240",
|
||||
competitor: "Conversion-focused",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Local AI tools",
|
||||
snapotter: "OCR, transcription, upscaling, more",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
{
|
||||
feature: "Layer-based image editor",
|
||||
snapotter: "Yes",
|
||||
competitor: "No",
|
||||
snapotterWins: true,
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
q: "Should I use SnapOtter or ConvertX?",
|
||||
a: "If you mainly need format conversion across many formats, ConvertX is a clean, focused option. If you also edit, compress, redact, OCR, or transcribe, SnapOtter does all of that in one stack.",
|
||||
},
|
||||
{
|
||||
q: "Do both keep files on my server?",
|
||||
a: "Yes. Both are self-hosted, so files stay on your infrastructure. The difference is breadth, not where your data lives.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const toolCount = TOOLS.length;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing for Enterprise`;
|
||||
const defaultDescription = `The self-hosted file-processing suite for teams that keep sensitive data in-house. ${toolCount} tools with local AI, SSO, audit logging, and full data control.`;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing (Image, Video, Audio, PDF, Files)`;
|
||||
const defaultDescription = `Self-hosted, open-source file toolkit. ${toolCount} tools across image, video, audio, PDF, and files. The open-source alternative to Smallpdf, iLovePDF, and CloudConvert, on infrastructure you own.`;
|
||||
|
||||
const {
|
||||
title = defaultTitle,
|
||||
@@ -56,7 +56,7 @@ const {
|
||||
<meta property="og:image" content={`https://snapotter.com${ogImage}`} />
|
||||
<meta property="og:image:width" content="1280" />
|
||||
<meta property="og:image:height" content="640" />
|
||||
<meta property="og:image:alt" content="SnapOtter - Self-Hosted File Processing for Enterprise" />
|
||||
<meta property="og:image:alt" content="SnapOtter - self-hosted file processing for image, video, audio, PDF, and files" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { normalizeSearchQuery } from "@snapotter/shared/search/format-aliases.js";
|
||||
|
||||
/** Bounded Levenshtein: returns edit distance, short-circuiting above `max`. */
|
||||
function editDistance(a: string, b: string, max: number): number {
|
||||
if (Math.abs(a.length - b.length) > max) return max + 1;
|
||||
const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let prev = dp[0];
|
||||
dp[0] = i;
|
||||
let rowMin = dp[0];
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = dp[j];
|
||||
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
|
||||
prev = tmp;
|
||||
if (dp[j] < rowMin) rowMin = dp[j];
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
}
|
||||
return dp[b.length];
|
||||
}
|
||||
|
||||
/** True if every normalized query token appears in the haystack (substring), or matches a haystack word within a small edit distance. */
|
||||
export function matchTool(rawQuery: string, haystack: string): boolean {
|
||||
const q = normalizeSearchQuery(rawQuery);
|
||||
if (!q) return true;
|
||||
const hay = haystack.toLowerCase();
|
||||
const hayWords = hay.split(/\s+/);
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every((tok) => {
|
||||
if (tok === "to") return true;
|
||||
if (hay.indexOf(tok) !== -1) return true;
|
||||
const max = tok.length <= 4 ? 1 : 2;
|
||||
return hayWords.some((w) => editDistance(tok, w, max) <= max);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
export function getStaticPaths() {
|
||||
return ALTERNATIVES.map((alt) => ({
|
||||
params: { slug: alt.slug },
|
||||
props: { alt },
|
||||
}));
|
||||
}
|
||||
|
||||
const { alt } = Astro.props;
|
||||
const canonical = `https://snapotter.com/alternatives/${alt.slug}`;
|
||||
const others = ALTERNATIVES.filter((a) => a.slug !== alt.slug);
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Alternatives",
|
||||
item: "https://snapotter.com/alternatives",
|
||||
},
|
||||
{ "@type": "ListItem", position: 3, name: alt.competitor, item: canonical },
|
||||
],
|
||||
};
|
||||
|
||||
const faqJsonLd =
|
||||
alt.faqs.length > 0
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: alt.faqs.map((faq) => ({
|
||||
"@type": "Question",
|
||||
name: faq.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: faq.a },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
|
||||
---
|
||||
|
||||
<Base title={`${alt.pageTitle} | SnapOtter`} description={alt.metaDescription} canonical={canonical}>
|
||||
<Fragment slot="head">
|
||||
<JsonLd data={allJsonLd} />
|
||||
</Fragment>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main id="main">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-muted">
|
||||
<li><a href="/" class="transition-colors hover:text-foreground">Home</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/alternatives" class="transition-colors hover:text-foreground">Alternatives</a>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="font-medium text-foreground">{alt.competitor}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-4xl px-6 pt-8 pb-12">
|
||||
<div class="reveal">
|
||||
<span class="rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-semibold text-primary-dark">
|
||||
{alt.category}
|
||||
</span>
|
||||
<h1 class="mt-5 font-display text-4xl font-bold tracking-tight md:text-5xl">
|
||||
{alt.h1}
|
||||
</h1>
|
||||
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
|
||||
{alt.intro}
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Deploy with Docker
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://demo.snapotter.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
Try the live demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison table -->
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
SnapOtter vs {alt.competitor}
|
||||
</h2>
|
||||
<div class="mt-8 overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-border bg-background-alt">
|
||||
<tr>
|
||||
<th class="px-5 py-4 text-start font-semibold"> </th>
|
||||
<th class="px-5 py-4 text-start font-semibold text-primary-dark">SnapOtter</th>
|
||||
<th class="px-5 py-4 text-start font-semibold">{alt.competitor}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alt.rows.map((row) => (
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="px-5 py-4 font-medium">{row.feature}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="flex items-start gap-2">
|
||||
{row.snapotterWins && (
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{row.snapotter}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-muted">{row.competitor}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-muted">
|
||||
Comparison reflects the open-source editions and publicly documented plans. Vendor
|
||||
features change; check current docs before deciding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Breadth callout -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal flex items-start gap-4">
|
||||
<svg class="mt-1 h-6 w-6 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="font-display text-xl font-bold">One stack, all five file types</h2>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">{alt.breadth}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQs -->
|
||||
{alt.faqs.length > 0 && (
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<dl class="mt-8 space-y-6">
|
||||
{alt.faqs.map((faq) => (
|
||||
<div class="rounded-xl border border-border bg-surface p-6">
|
||||
<dt class="text-sm font-semibold">{faq.q}</dt>
|
||||
<dd class="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Other comparisons -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight">More comparisons</h2>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{others.map((o) => (
|
||||
<a
|
||||
href={`/alternatives/${o.slug}`}
|
||||
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
<span class="text-sm font-bold">SnapOtter vs {o.competitor}</span>
|
||||
<span class="mt-1 text-xs text-muted">{o.category}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<section class="border-t border-border py-20">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight">
|
||||
Run it on your own server
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-lg text-muted">
|
||||
240 tools across image, video, audio, PDF, and files. Open source, free forever, and your
|
||||
files never leave your network.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Get started
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="/alternatives"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
All alternatives
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
const canonical = "https://snapotter.com/alternatives";
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
|
||||
{ "@type": "ListItem", position: 2, name: "Alternatives", item: canonical },
|
||||
],
|
||||
};
|
||||
|
||||
const itemListJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
itemListElement: ALTERNATIVES.map((alt, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: alt.pageTitle,
|
||||
url: `https://snapotter.com/alternatives/${alt.slug}`,
|
||||
})),
|
||||
};
|
||||
|
||||
const allJsonLd = [breadcrumbJsonLd, itemListJsonLd];
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Open-Source, Self-Hosted Alternatives to Cloud File Tools | SnapOtter"
|
||||
description="Open-source, self-hosted alternatives to Smallpdf, iLovePDF, TinyPNG, CloudConvert, and Otter.ai. One stack for image, video, audio, PDF, and files, on infrastructure you own."
|
||||
canonical={canonical}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<JsonLd data={allJsonLd} />
|
||||
</Fragment>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main id="main">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-4xl px-6 pt-28 pb-12 text-center md:pt-36">
|
||||
<div class="reveal">
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
|
||||
Open-source alternatives to cloud file tools
|
||||
</h1>
|
||||
<p class="mx-auto mt-5 max-w-2xl text-lg leading-relaxed text-muted md:text-xl">
|
||||
Most file tools upload your work to someone else's server. SnapOtter runs the same kinds of
|
||||
tools on yours: 240 of them across image, video, audio, PDF, and files, in one self-hosted
|
||||
stack.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16">
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ALTERNATIVES.map((alt) => (
|
||||
<a
|
||||
href={`/alternatives/${alt.slug}`}
|
||||
class="group flex flex-col rounded-xl border border-border bg-surface p-6 transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
<span class="text-xs font-semibold text-primary-dark">{alt.category}</span>
|
||||
<span class="mt-2 font-display text-lg font-bold tracking-tight">
|
||||
{alt.competitorOpenSource
|
||||
? `SnapOtter vs ${alt.competitor}`
|
||||
: `The alternative to ${alt.competitor}`}
|
||||
</span>
|
||||
<span class="mt-2 text-sm leading-relaxed text-muted">{alt.intro}</span>
|
||||
<span class="mt-auto inline-flex items-center gap-1 pt-5 text-sm font-medium text-primary transition-all group-hover:gap-2">
|
||||
Compare
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why self-hosted -->
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Why teams pick the self-hosted route
|
||||
</h2>
|
||||
<div class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Your files stay yours</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
Processing happens on hardware you control. Nothing is uploaded to a third-party
|
||||
service, which matters when the file is a contract, a medical scan, or unreleased work.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">No quotas or per-file fees</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
The only limit is your machine. No monthly cap, no per-conversion billing, no
|
||||
watermarks on the free output.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">One stack, five file types</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
Image, video, audio, PDF, and files in a single deployment, instead of a separate
|
||||
account for each job.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Open source, auditable</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
AGPLv3. Your team can read every line, run it air-gapped, and keep it as long as you
|
||||
like.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<section class="py-20">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight">Run it on your own server</h2>
|
||||
<p class="mx-auto mt-4 max-w-lg text-muted">
|
||||
Open source, free forever, and your files never leave your network.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="https://docs.snapotter.com/guide/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
|
||||
>
|
||||
Get started
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/snapotter-hq/snapotter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -33,7 +33,7 @@ const faqs = [
|
||||
{
|
||||
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 file content is ever collected. You have full control over this in the settings and can disable it at any time.",
|
||||
"Anonymous product analytics is on by default and never includes your files or their contents. Turn it off any time with a one-click admin toggle in Settings > System > Privacy.",
|
||||
},
|
||||
{
|
||||
question: "Can I use SnapOtter in my company?",
|
||||
|
||||
@@ -86,12 +86,18 @@ const breadcrumbJsonLd = {
|
||||
<h2 class="text-lg font-semibold text-foreground">Analytics</h2>
|
||||
<ul class="mt-3 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
Basic analytics (tool usage patterns, error reports) help improve the software.
|
||||
Your files and personal data are never included.
|
||||
Anonymous product analytics (tool usage patterns, error reports) help improve the
|
||||
software. Your files, file names, and personal data are never included.
|
||||
</li>
|
||||
<li>
|
||||
You can disable analytics at any time -- SnapOtter works normally without it.
|
||||
Rebuild with <code class="text-xs">--build-arg SNAPOTTER_ANALYTICS=off</code>.
|
||||
It is on by default. An administrator can turn it off any time with a one-click
|
||||
toggle under Settings > System > Privacy. No rebuild is required, and it stops
|
||||
immediately for the whole instance.
|
||||
</li>
|
||||
<li>
|
||||
To build an image that can never emit analytics, set the
|
||||
<code class="text-xs">SNAPOTTER_ANALYTICS=off</code> build arg as a compile-time
|
||||
hard-off.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -190,6 +190,7 @@ const collectionJsonLd = {
|
||||
data-name={tool.name.toLowerCase()}
|
||||
data-desc={tool.description.toLowerCase()}
|
||||
data-modality={tool.modality}
|
||||
data-keywords={(tool.keywords ?? []).join(" ")}
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
@@ -265,64 +266,63 @@ const collectionJsonLd = {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var search = document.getElementById("tools-search");
|
||||
var countEl = document.getElementById("tools-count");
|
||||
var emptyEl = document.getElementById("tools-empty");
|
||||
var items = Array.prototype.slice.call(document.querySelectorAll(".tool-item"));
|
||||
var sections = Array.prototype.slice.call(document.querySelectorAll("[data-cat-section]"));
|
||||
var modBtns = Array.prototype.slice.call(document.querySelectorAll(".tools-mod-btn"));
|
||||
var total = items.length;
|
||||
var activeMod = "all";
|
||||
<script>
|
||||
import { matchTool } from "../../lib/tool-search";
|
||||
|
||||
function setBtns() {
|
||||
modBtns.forEach(function (b) {
|
||||
var on = b.getAttribute("data-modality") === activeMod;
|
||||
const search = document.getElementById("tools-search") as HTMLInputElement | null;
|
||||
const countEl = document.getElementById("tools-count");
|
||||
const emptyEl = document.getElementById("tools-empty");
|
||||
const items = Array.from(document.querySelectorAll<HTMLElement>(".tool-item"));
|
||||
const sections = Array.from(document.querySelectorAll<HTMLElement>("[data-cat-section]"));
|
||||
const modBtns = Array.from(document.querySelectorAll<HTMLElement>(".tools-mod-btn"));
|
||||
const total = items.length;
|
||||
let activeMod = "all";
|
||||
|
||||
if (search && countEl && emptyEl) {
|
||||
const setBtns = () => {
|
||||
for (const b of modBtns) {
|
||||
const on = b.getAttribute("data-modality") === activeMod;
|
||||
b.className =
|
||||
"tools-mod-btn shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors " +
|
||||
(on ? "bg-primary text-white" : "border border-border bg-surface hover:bg-background-alt");
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function filter() {
|
||||
var q = search.value.toLowerCase().trim();
|
||||
var visible = 0;
|
||||
items.forEach(function (el) {
|
||||
var matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
|
||||
var matchQ =
|
||||
!q ||
|
||||
el.getAttribute("data-name").indexOf(q) !== -1 ||
|
||||
el.getAttribute("data-desc").indexOf(q) !== -1;
|
||||
var show = matchMod && matchQ;
|
||||
const filter = () => {
|
||||
const q = search.value.trim();
|
||||
let visible = 0;
|
||||
for (const el of items) {
|
||||
const matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
|
||||
const hay =
|
||||
`${el.getAttribute("data-name") || ""} ${el.getAttribute("data-desc") || ""} ` +
|
||||
`${el.getAttribute("data-keywords") || ""}`;
|
||||
const matchQ = !q || matchTool(q, hay);
|
||||
const show = matchMod && matchQ;
|
||||
el.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
sections.forEach(function (sec) {
|
||||
var anyVisible = Array.prototype.some.call(
|
||||
sec.querySelectorAll(".tool-item"),
|
||||
function (el) {
|
||||
return el.style.display !== "none";
|
||||
},
|
||||
}
|
||||
for (const sec of sections) {
|
||||
const anyVisible = Array.from(sec.querySelectorAll<HTMLElement>(".tool-item")).some(
|
||||
(el) => el.style.display !== "none",
|
||||
);
|
||||
sec.style.display = anyVisible ? "" : "none";
|
||||
});
|
||||
}
|
||||
emptyEl.style.display = visible === 0 ? "block" : "none";
|
||||
countEl.textContent =
|
||||
q || activeMod !== "all"
|
||||
? "Showing " + visible + " of " + total + " tools"
|
||||
: "Showing all " + total + " tools";
|
||||
}
|
||||
? `Showing ${visible} of ${total} tools`
|
||||
: `Showing all ${total} tools`;
|
||||
};
|
||||
|
||||
search.addEventListener("input", filter);
|
||||
modBtns.forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
activeMod = b.getAttribute("data-modality");
|
||||
for (const b of modBtns) {
|
||||
b.addEventListener("click", () => {
|
||||
activeMod = b.getAttribute("data-modality") ?? "all";
|
||||
setBtns();
|
||||
filter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Footer />
|
||||
|
||||
Reference in New Issue
Block a user