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:
SnapOtter
2026-06-28 18:57:53 +08:00
committed by GitHub
parent 88af8d46fb
commit 63a03d26f2
421 changed files with 17308 additions and 2540 deletions
+23 -7
View File
@@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer";
import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile";
import { initAnalytics, track } from "./lib/analytics";
import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics";
import { useAnalyticsStore } from "./stores/analytics-store";
// Lazy-load all pages so each page's JS (and its icons/deps) is only
@@ -48,13 +48,12 @@ class ErrorBoundary extends Component<
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught render error:", error, info.componentStack);
// Mirror the crash to PostHog (error class only, no PII). track() is best-effort.
if (!isAnalyticsActive()) return; // respect the runtime opt-out
// Mirror the crash class only (no PII). track() and Sentry are best-effort.
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
import("@sentry/react")
.then((Sentry) => {
Sentry.captureException(error, {
contexts: { react: { componentStack: info.componentStack ?? undefined } },
});
Sentry.captureException(error);
})
.catch(() => {});
}
@@ -160,10 +159,27 @@ export function App() {
}, []);
useEffect(() => {
if (!analyticsConfigLoaded || !analyticsConfig?.enabled) return;
void initAnalytics(analyticsConfig);
if (!analyticsConfigLoaded) return;
if (analyticsConfig?.enabled) {
void initAnalytics(analyticsConfig);
} else if (isAnalyticsActive()) {
// Instance-wide opt-out observed after this tab already initialized.
optOut();
}
}, [analyticsConfigLoaded, analyticsConfig]);
useEffect(() => {
const refetch = () => {
if (document.visibilityState === "visible") void fetchAnalyticsConfig();
};
document.addEventListener("visibilitychange", refetch);
window.addEventListener("focus", refetch);
return () => {
document.removeEventListener("visibilitychange", refetch);
window.removeEventListener("focus", refetch);
};
}, [fetchAnalyticsConfig]);
return (
<ErrorBoundary>
<I18nProvider>
@@ -0,0 +1,78 @@
import { FEATURE_BUNDLES, type PipelineTemplate, templateRequiredBundles } from "@snapotter/shared";
import { Download, Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { getTemplateDescription, getTemplateName } from "@/lib/template-i18n";
import { getToolName } from "@/lib/tool-i18n";
import { useFeaturesStore } from "@/stores/features-store";
interface TemplateCardProps {
template: PipelineTemplate;
onUse: (template: PipelineTemplate) => void;
}
export function TemplateCard({ template, onUse }: TemplateCardProps) {
const { t } = useTranslation();
const bundles = useFeaturesStore((s) => s.bundles);
const installing = useFeaturesStore((s) => s.installing);
const requiredBundles = useMemo(() => templateRequiredBundles(template), [template]);
const name = getTemplateName(t, template.id, template.id);
const description = getTemplateDescription(t, template.id, "");
return (
<div
data-testid={`template-card-${template.id}`}
className="flex flex-col gap-1.5 p-2 rounded border border-border hover:border-primary/40 transition-colors"
>
<button
type="button"
onClick={() => onUse(template)}
className="text-start"
data-testid={`template-use-${template.id}`}
>
<span className="text-xs font-medium text-foreground hover:text-primary">{name}</span>
{description ? (
<span className="block text-[11px] text-muted-foreground mt-0.5">{description}</span>
) : null}
</button>
<div className="flex flex-wrap items-center gap-1">
{template.steps.map((step, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: steps are an ordered, static list with no stable id and may repeat a toolId
key={`${step.toolId}-${i}`}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
{getToolName(t, step.toolId, step.toolId)}
</span>
))}
</div>
{requiredBundles.length > 0 ? (
<div className="flex flex-wrap items-center gap-1">
{requiredBundles.map((bundleId) => {
const installed = bundles.find((b) => b.id === bundleId)?.status === "installed";
const isInstalling = Boolean(installing[bundleId]);
const label = FEATURE_BUNDLES[bundleId]?.name ?? bundleId;
return (
<span
key={bundleId}
data-testid={`template-bundle-${template.id}-${bundleId}`}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-primary/5 text-muted-foreground"
>
{isInstalling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : installed ? null : (
<Download className="h-3 w-3" />
)}
{label}
</span>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,45 @@
import { type Modality, PIPELINE_TEMPLATES, type PipelineTemplate } from "@snapotter/shared";
import { useTranslation } from "@/contexts/i18n-context";
import { getModalityName } from "@/lib/tool-i18n";
import { TemplateCard } from "./template-card";
const MODALITY_ORDER: Modality[] = ["image", "document", "audio", "video", "file"];
interface TemplatesSectionProps {
onUse: (template: PipelineTemplate) => void;
emphasized: boolean;
}
export function TemplatesSection({ onUse, emphasized }: TemplatesSectionProps) {
const { t } = useTranslation();
const groups = MODALITY_ORDER.map((modality) => ({
modality,
templates: PIPELINE_TEMPLATES.filter((tpl) => tpl.modality === modality),
})).filter((g) => g.templates.length > 0);
return (
<div
data-testid="templates-section"
className={`px-3 py-2 border-t border-border shrink-0 ${emphasized ? "bg-muted/30" : ""}`}
>
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1.5">
{t.automate.templatesLabel}
</h3>
<div className="space-y-3 max-h-72 overflow-y-auto">
{groups.map((group) => (
<div key={group.modality}>
<div className="text-[11px] font-medium text-muted-foreground mb-1">
{getModalityName(t, group.modality, group.modality)}
</div>
<div className="grid grid-cols-1 gap-1.5">
{group.templates.map((tpl) => (
<TemplateCard key={tpl.id} template={tpl} onUse={onUse} />
))}
</div>
</div>
))}
</div>
</div>
);
}
+12 -1
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
import { useSettingsStore } from "@/stores/settings-store";
import { HelpDialog } from "../help/help-dialog";
import { SettingsDialog } from "../settings/settings-dialog";
import { AiInstallIndicator } from "./ai-install-indicator";
@@ -21,6 +22,16 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
// Load global settings (disabled tools, experimental flag, default theme) on
// every authenticated page, not just the home grid. Without this, navigating
// directly to a tool URL leaves disabledTools empty, so an admin-disabled tool
// renders normally instead of showing the disabled message. fetch() is a
// no-op once loaded, so this is cheap on subsequent navigations.
const fetchSettings = useSettingsStore((s) => s.fetch);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
return (
<div
className={cn(
@@ -52,8 +52,9 @@ export function AvatarDropdown({ onSettingsClick, variant = "light" }: AvatarDro
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-semibold hover:opacity-90 transition-opacity"
className="w-7 h-7 rounded-full bg-primary text-[#1a1814] flex items-center justify-center text-xs font-semibold hover:opacity-90 transition-opacity"
aria-label={username}
data-testid="user-menu"
>
{initial}
</button>
@@ -36,6 +36,7 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@
import { format, plural } from "@/lib/format";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn, copyToClipboard } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
import { OtterLogo } from "../common/otter-logo";
@@ -362,10 +363,10 @@ function GeneralSection() {
role: "unknown",
});
}),
apiGet<{ settings: Record<string, string> }>("/v1/settings")
apiGet<{ preferences: Record<string, unknown> }>("/v1/preferences")
.then((data) => {
if (data.settings.defaultToolView) {
setDefaultToolView(data.settings.defaultToolView);
if (typeof data.preferences.defaultToolView === "string") {
setDefaultToolView(data.preferences.defaultToolView);
}
})
.catch(() => {}),
@@ -397,7 +398,10 @@ function GeneralSection() {
setSaving(true);
setSaveMsg(null);
try {
await apiPut("/v1/settings", { defaultToolView });
// The default home view is a per-user preference, not instance config, so
// it saves to /v1/preferences (writable by any authenticated user) rather
// than the admin-only /v1/settings.
await apiPut("/v1/preferences", { defaultToolView });
setSaveMsg(t.settings.general.saveSuccess);
useSettingsStore.setState({
defaultToolView: defaultToolView as "sidebar" | "fullscreen",
@@ -549,7 +553,24 @@ function SystemSection() {
setSaving(true);
setSaveMsg(null);
try {
await apiPut("/v1/settings", settings);
// GET returns read-only keys (instance_id, cookie_secret) and shows redacted
// secrets as the literal "********". Echoing those back fails with 400
// READONLY_SETTING or would overwrite a real secret with the mask, so send
// only the keys this section can actually change.
const READONLY_KEYS = new Set(["instance_id", "cookie_secret"]);
const writable = Object.fromEntries(
Object.entries(settings).filter(
([key, value]) => !READONLY_KEYS.has(key) && value !== "********",
),
);
await apiPut("/v1/settings", writable);
if (settings.analyticsEnabled === "false") {
const { optOut } = await import("@/lib/analytics");
optOut();
} else {
// Re-enabling takes effect on the next config refetch / reload.
useAnalyticsStore.getState().fetchConfig();
}
if (settings.defaultTheme) {
const theme = settings.defaultTheme as "light" | "dark" | "system";
useThemeStore.getState().setTheme(theme);
@@ -685,6 +706,39 @@ function SystemSection() {
</button>
</SettingRow>
<div className="pt-4 border-t border-border">
<h4 className="text-sm font-semibold text-foreground mb-3">{t.settings.privacy.title}</h4>
<p className="text-sm text-muted-foreground mb-3">{t.settings.privacy.description}</p>
</div>
<SettingRow
label={t.settings.privacy.analyticsLabel}
description={t.settings.privacy.analyticsDescription}
>
<button
type="button"
role="switch"
aria-checked={settings.analyticsEnabled !== "false"}
aria-label={t.settings.privacy.analyticsLabel}
onClick={() =>
updateSetting(
"analyticsEnabled",
settings.analyticsEnabled === "false" ? "true" : "false",
)
}
className={cn(
"w-11 h-6 rounded-full transition-colors relative",
settings.analyticsEnabled !== "false" ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
settings.analyticsEnabled !== "false" ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</SettingRow>
<div className="pt-4 border-t border-border">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t.settings.dataRetention.title}
@@ -1631,6 +1685,10 @@ function PeopleSection() {
setShowAddForm(false);
setShowGeneratedPw(false);
setPwCopied(false);
// Reset the field values too, so re-opening the form is clean.
setNewUsername("");
setNewPassword("");
setAddError(null);
}}
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
@@ -2415,8 +2473,17 @@ function TeamsSection() {
className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40"
ref={(el) => el?.focus()}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(tm.id);
if (e.key === "Escape") setEditingTeamId(null);
// Keep Enter/Escape scoped to the rename input. Without
// stopping propagation, Escape also reaches the dialog's
// global Escape handler and closes the whole dialog.
if (e.key === "Enter") {
e.stopPropagation();
handleRename(tm.id);
}
if (e.key === "Escape") {
e.stopPropagation();
setEditingTeamId(null);
}
}}
/>
<button
@@ -14,6 +14,15 @@ export interface CompressControlsProps {
onChange?: (settings: Record<string, unknown>) => void;
}
/** Stable serialization (sorted keys) for comparing two settings payloads. */
function canonicalSettings(settings: Record<string, unknown>): string {
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(settings).sort()) {
sorted[key] = settings[key];
}
return JSON.stringify(sorted);
}
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<CompressMode>("targetSize");
@@ -21,12 +30,15 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
const [targetSizeValue, setTargetSizeValue] = useState("");
const [sizeUnit, setSizeUnit] = useState<SizeUnit>("KB");
const prevSettingsKeyRef = useRef<string | null>(null);
// Seed local state from preloaded settings exactly once (a pipeline step can
// mount this control with non-default settings). One-time, not a re-sync: a
// re-sync would keep pulling the store's value back into local state and fight
// the emit effect below, ping-ponging into an infinite render loop (React
// error #185). Mirrors resize-settings / convert-settings.
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings) return;
const key = JSON.stringify(initialSettings);
if (prevSettingsKeyRef.current === key) return;
prevSettingsKeyRef.current = key;
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.mode != null) setMode(initialSettings.mode as CompressMode);
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
if (initialSettings.targetSizeKb != null)
@@ -38,15 +50,28 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
onChangeRef.current = onChange;
});
// Emit settings on change. Once local state has converged on initialSettings
// (a preloaded pipeline step), the computed payload equals initialSettings, so
// we skip the echo. A genuine user edit produces a payload that differs, so it
// still emits. This idempotent guard replaces an earlier one-shot flag that
// could dangle and silently swallow a real edit when the sync effect's
// setState calls were no-ops; it has no such failure mode.
useEffect(() => {
if (mode === "quality") {
onChangeRef.current?.({ mode, quality });
} else {
const valueNum = Number(targetSizeValue);
const targetSizeKb = sizeUnit === "MB" ? valueNum * 1024 : valueNum;
onChangeRef.current?.({ mode, targetSizeKb });
const next: Record<string, unknown> =
mode === "quality"
? { mode, quality }
: {
mode,
targetSizeKb:
sizeUnit === "MB" ? Number(targetSizeValue) * 1024 : Number(targetSizeValue),
};
if (initialSettings && canonicalSettings(next) === canonicalSettings(initialSettings)) {
return;
}
}, [mode, quality, targetSizeValue, sizeUnit]);
onChangeRef.current?.(next);
}, [mode, quality, targetSizeValue, sizeUnit, initialSettings]);
return (
<div className="space-y-4">
@@ -0,0 +1,170 @@
import { BASE_CONFIG, CONVERSION_PRESET_BY_ID } from "@snapotter/shared";
import { Download } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
/** Target formats that honor a quality knob (lossy raster encodings). */
const LOSSY = new Set(["jpg", "jpeg", "webp", "avif"]);
/**
* One settings panel shared by every conversion preset. Reads the active tool
* id from the route (the /:section/:toolId param), looks up the preset and its
* base settingsKind from shared metadata, and renders the matching controls:
* a quality slider for lossy raster/svg targets, a quality select for
* convert-video, page size + orientation for image-to-pdf, nothing otherwise.
*/
export function ConversionPresetSettings() {
const { t } = useTranslation();
const params = useParams<{ toolId: string }>();
const toolId = params.toolId ?? "";
const preset = CONVERSION_PRESET_BY_ID[toolId];
const kind = preset ? BASE_CONFIG[preset.base].settingsKind : "none";
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor(toolId);
const [quality, setQuality] = useState(85);
const [videoQuality, setVideoQuality] = useState<"high" | "balanced" | "small">("balanced");
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
const hasFile = files.length > 0;
const targetIsLossy = preset ? LOSSY.has(String(preset.locked.format ?? "")) : false;
const buildSettings = (): Record<string, unknown> => {
if (kind === "quality" && targetIsLossy) return { quality };
if (kind === "video") return { quality: videoQuality };
if (kind === "pdf") return { pageSize, orientation };
return {};
};
const handleProcess = () => {
const settings = buildSettings();
if (files.length > 1) processAllFiles(files, settings);
else processFiles(files, settings);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && !processing) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{kind === "quality" && targetIsLossy && (
<div>
<div className="flex justify-between items-center">
<label htmlFor="preset-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="preset-quality"
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
)}
{kind === "video" && (
<div>
<label htmlFor="preset-vq" className="text-xs text-muted-foreground">
Quality
</label>
<select
id="preset-vq"
value={videoQuality}
onChange={(e) => setVideoQuality(e.target.value as "high" | "balanced" | "small")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="high">High</option>
<option value="balanced">Balanced</option>
<option value="small">Small</option>
</select>
</div>
)}
{kind === "pdf" && (
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="preset-page" className="text-xs text-muted-foreground">
Page size
</label>
<select
id="preset-page"
value={pageSize}
onChange={(e) => setPageSize(e.target.value as "A4" | "Letter" | "A3" | "A5")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="A4">A4</option>
<option value="Letter">Letter</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
</select>
</div>
<div>
<label htmlFor="preset-orient" className="text-xs text-muted-foreground">
Orientation
</label>
<select
id="preset-orient"
value={orientation}
onChange={(e) => setOrientation(e.target.value as "portrait" | "landscape")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="portrait">Portrait</option>
<option value="landscape">Landscape</option>
</select>
</div>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings.convert.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="preset-submit"
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{files.length > 1
? format(t.toolSettings.convert.submitBatch, { count: files.length })
: t.toolSettings.convert.submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="preset-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -103,7 +103,14 @@ export function CropCanvas({
crop={crop}
onChange={(_pixelCrop, percentCrop) => onCropChange(percentCrop)}
aspect={aspect}
className="max-h-full"
// ReactCrop's own CSS forces `.ReactCrop__child-wrapper>img` to
// max-height:inherit, which overrides the img's own max-h utility
// (higher specificity). So the viewport cap has to live on the
// ReactCrop element itself; the child-wrapper and img then inherit
// it. Without this a tall portrait renders at full natural height
// and overflows the viewport. max-h-full doesn't help because the
// ancestor chain isn't reliably height-bounded.
className="max-h-[calc(100dvh-12rem)]"
ruleOfThirds={showGrid}
>
<img
@@ -86,7 +86,7 @@ export interface RemoveBgControlsProps {
onChange: (settings: Record<string, unknown>) => void;
}
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) {
const { t } = useTranslation();
const [subject, setSubject] = useState<SubjectType>("people");
const [quality, setQuality] = useState<Quality>("balanced");
@@ -116,6 +116,39 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
// Expandable sections
const [effectsOpen, setEffectsOpen] = useState(false);
// Seed local state from preloaded settings exactly once. Pipeline steps (e.g.
// a template) mount this control with non-default settings; without seeding,
// the emit effect below would overwrite them with the hardcoded defaults.
// Mirrors the initializedRef pattern in resize-settings / convert-settings.
const initializedRef = useRef(false);
useEffect(() => {
if (!settings || initializedRef.current) return;
initializedRef.current = true;
if (settings.backgroundType != null) setBgType(settings.backgroundType as BackgroundType);
if (settings.backgroundColor != null) setBgColor(String(settings.backgroundColor));
if (settings.gradientColor1 != null) setGradColor1(String(settings.gradientColor1));
if (settings.gradientColor2 != null) setGradColor2(String(settings.gradientColor2));
if (settings.gradientAngle != null) setGradAngle(Number(settings.gradientAngle));
if (settings.blurEnabled != null) setBlurEnabled(Boolean(settings.blurEnabled));
if (settings.blurIntensity != null) setBlurIntensity(Number(settings.blurIntensity));
if (settings.shadowEnabled != null) setShadowEnabled(Boolean(settings.shadowEnabled));
if (settings.shadowOpacity != null) setShadowOpacity(Number(settings.shadowOpacity));
if (settings.edgeRefine != null) setEdgeRefine(Number(settings.edgeRefine));
if (settings.decontaminate != null) setDecontaminate(Boolean(settings.decontaminate));
if (settings.outputFormat != null)
setOutputFormat(settings.outputFormat as "png" | "webp" | "avif");
// Reveal the effects section when any seeded effect is active so the
// preloaded values are immediately visible (and adjustable).
if (
settings.blurEnabled ||
settings.shadowEnabled ||
settings.edgeRefine ||
settings.decontaminate
) {
setEffectsOpen(true);
}
}, [settings]);
// Filter quality options based on subject (Ultra only for People)
const qualityOptions = ALL_QUALITY_OPTIONS.filter(
(opt) => !opt.peopleOnly || subject === "people",
@@ -409,6 +442,8 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
<button
key={fmt}
type="button"
data-testid={`remove-background-format-${fmt}`}
aria-pressed={outputFormat === fmt}
onClick={() => setOutputFormat(fmt)}
className={`py-2 px-2 rounded-lg border text-xs font-medium uppercase transition-colors ${
outputFormat === fmt
@@ -520,6 +555,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
</div>
<input
type="range"
data-testid="remove-background-edge-refine"
min={0}
max={3}
step={1}
@@ -534,6 +570,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
data-testid="remove-background-decontaminate"
checked={decontaminate}
onChange={(e) => setDecontaminate(e.target.checked)}
className="rounded border-border accent-primary"
@@ -3,6 +3,7 @@ import { FileImage, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { useTranslation } from "@/contexts/i18n-context";
import { useFuseSearch } from "@/hooks/use-fuse-search";
import { apiGet } from "@/lib/api";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
@@ -37,19 +38,17 @@ export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
.catch(() => {});
}, []);
const availableTools = useMemo(() => {
const q = search.toLowerCase();
const preFiltered = useMemo(() => {
return TOOLS.filter((t) => {
if (EXCLUDED_TOOLS.has(t.id)) return false;
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
return false;
}
return true;
});
}, [disabledTools, experimentalEnabled, pipelineToolIds, search]);
}, [disabledTools, experimentalEnabled, pipelineToolIds]);
const availableTools = useFuseSearch(preFiltered, search);
const groupedTools = useMemo(() => {
const groups: Record<string, typeof availableTools> = {};
+7 -1
View File
@@ -1,4 +1,5 @@
import type { Tool } from "@snapotter/shared";
import { normalizeSearchQuery } from "@snapotter/shared";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { useMemo } from "react";
@@ -6,6 +7,7 @@ import { useMemo } from "react";
const FUSE_OPTIONS: IFuseOptions<Tool> = {
keys: [
{ name: "name", weight: 0.35 },
{ name: "keywords", weight: 0.3 },
{ name: "description", weight: 0.25 },
{ name: "modality", weight: 0.15 },
{ name: "id", weight: 0.15 },
@@ -19,12 +21,16 @@ const FUSE_OPTIONS: IFuseOptions<Tool> = {
/**
* Wraps a tool list with Fuse.js fuzzy search.
* Returns all tools when query is empty; ranked fuzzy results otherwise.
* Queries are normalized (synonyms, compact forms like "jpg2png", filler
* stripping) before searching, falling back to the raw query when
* normalization yields an empty string.
*/
export function useFuseSearch(tools: Tool[], query: string): Tool[] {
const fuse = useMemo(() => new Fuse(tools, FUSE_OPTIONS), [tools]);
return useMemo(() => {
if (!query) return tools;
return fuse.search(query).map((r) => r.item);
const normalized = normalizeSearchQuery(query);
return fuse.search(normalized || query).map((r) => r.item);
}, [fuse, query, tools]);
}
+67 -31
View File
@@ -4,15 +4,35 @@ type PostHogInstance = import("posthog-js").PostHog;
let posthog: PostHogInstance | null = null;
let initialized = false;
let enabled = false; // live runtime flag; gates track() and ErrorBoundary capture
const FILE_EXT_PATTERN =
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
const FILE_EXT_TEST =
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/i;
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g;
function basename(p: string): string {
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i >= 0 ? p.slice(i + 1) : p;
}
function scrubString(str: string): string {
return str.replace(FILE_EXT_PATTERN, ".[REDACTED]").replace(FILE_PATH_PATTERN, "/[REDACTED]/");
// Only these keys may leave the browser per event, and only as primitives.
const ALLOWED: Record<string, ReadonlySet<string>> = {
tool_opened: new Set(["tool_id", "category", "modality"]),
file_added: new Set(["tool_id", "count", "file_count"]),
tool_started: new Set(["tool_id", "is_batch", "file_count"]),
tool_client_error: new Set(["error_name"]),
result_downloaded: new Set(["tool_id"]),
result_saved: new Set(["tool_id"]),
search: new Set(["results_count", "clicked_tool_id"]),
ai_bundle_prompted: new Set(["bundle_id"]),
batch_processed: new Set(["tool_id", "file_count", "status"]),
};
function sanitize(event: string, properties?: Record<string, unknown>): Record<string, unknown> {
const allow = ALLOWED[event];
if (!allow || !properties) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(properties)) {
const t = typeof v;
if (allow.has(k) && (t === "string" || t === "number" || t === "boolean")) out[k] = v;
}
return out;
}
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
@@ -28,18 +48,17 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
disable_session_recording: true,
ip: false,
persistence: "localStorage",
person_profiles: "always",
person_profiles: "identified_only",
}) ?? null;
initialized = true;
enabled = true;
} catch (err) {
console.warn("[analytics] PostHog init failed:", err);
}
if (posthog) {
posthog.register({
instance_id: config.instanceId,
app_version: (await import("@snapotter/shared")).APP_VERSION,
});
// app_version only; no instance_id, so plain events stay person-less.
posthog.register({ app_version: (await import("@snapotter/shared")).APP_VERSION });
}
try {
@@ -53,32 +72,30 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
sendDefaultPii: false,
integrations: [Sentry.browserTracingIntegration()],
beforeSend(event) {
if (event.user) {
delete event.user.email;
delete event.user.username;
}
if (!enabled) return null;
event.message = undefined;
event.logentry = undefined;
event.request = undefined;
event.extra = undefined;
event.contexts = undefined;
event.breadcrumbs = undefined;
event.user = undefined;
if (event.exception?.values) {
for (const ex of event.exception.values) {
if (ex.value) ex.value = scrubString(ex.value);
ex.value = ex.type;
if (ex.stacktrace?.frames) {
for (const frame of ex.stacktrace.frames) {
if (frame.filename) frame.filename = scrubString(frame.filename);
if (frame.abs_path) frame.abs_path = scrubString(frame.abs_path);
if (frame.filename) frame.filename = basename(frame.filename);
frame.abs_path = undefined;
frame.vars = undefined;
}
}
}
}
return event;
},
beforeBreadcrumb(breadcrumb) {
if (breadcrumb.category === "ui.click") return null;
if (breadcrumb.category === "fetch" && breadcrumb.data?.url) {
if (FILE_EXT_TEST.test(breadcrumb.data.url as string)) return null;
}
if (breadcrumb.message) {
breadcrumb.message = scrubString(breadcrumb.message);
}
return breadcrumb;
beforeBreadcrumb() {
return null;
},
});
}
@@ -88,19 +105,38 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
}
export function track(event: string, properties?: Record<string, unknown>): void {
if (!posthog) return;
if (!enabled || !posthog) return;
try {
posthog.capture(event, properties);
posthog.capture(event, sanitize(event, properties));
} catch {
// never throw
}
}
export function getDistinctId(): string | null {
if (!posthog) return null;
if (!enabled || !posthog) return null;
try {
return posthog.get_distinct_id();
} catch {
return null;
}
}
export function isAnalyticsActive(): boolean {
return enabled && !!posthog;
}
/** Hard runtime opt-out: stop PostHog and Sentry in this tab without a reload. */
export function optOut(): void {
enabled = false;
try {
posthog?.opt_out_capturing();
} catch {
// ignore
}
void import("@sentry/react")
.then((Sentry) => {
Sentry.getClient()?.close();
})
.catch(() => {});
}
+11
View File
@@ -0,0 +1,11 @@
import type { TranslationKeys } from "@snapotter/shared";
export function getTemplateName(t: TranslationKeys, id: string, fallback: string): string {
const entry = (t.pipelineTemplates as Record<string, { name?: string }>)[id];
return entry?.name ?? fallback;
}
export function getTemplateDescription(t: TranslationKeys, id: string, fallback: string): string {
const entry = (t.pipelineTemplates as Record<string, { description?: string }>)[id];
return entry?.description ?? fallback;
}
+16
View File
@@ -6,6 +6,8 @@
* truth for display modes; tool-registry.tsx merges it into registry entries.
*/
import { BASE_CONFIG, CONVERSION_PRESETS } from "@snapotter/shared";
export type DisplayMode =
| "side-by-side"
| "before-after"
@@ -205,6 +207,20 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"extract-zip": "no-comparison",
};
// Each conversion preset mirrors the display mode of its base tool (e.g.
// jpg-to-png follows "convert"). Generated here so the 83 presets never drift
// from the static map above. Every displayBase is one of the keys defined
// statically, so the lookup always resolves.
for (const preset of CONVERSION_PRESETS) {
const baseMode = TOOL_DISPLAY_MODES[BASE_CONFIG[preset.base].displayBase];
// The pdf-to-image base renders "custom-results" with a dedicated page-picker
// UI backed by usePdfToImageStore. Presets use the shared
// ConversionPresetSettings + generic download flow instead, which has no such
// ResultsPanel, so they render as a plain converter (no-comparison) like the
// sibling image-to-pdf / convert-spreadsheet presets.
TOOL_DISPLAY_MODES[preset.id] = baseMode === "custom-results" ? "no-comparison" : baseMode;
}
/**
* Tools whose selected files all post in ONE request as repeated "file" parts.
* Consumed by use-tool-processor; backend routes declare maxInputs.
+27 -2
View File
@@ -5,7 +5,13 @@
* Adding a new tool means adding one entry here instead of editing a 750-line file.
*/
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
import {
AUDIO_INPUTS,
CONVERSION_PRESETS,
IMAGE_INPUTS,
SUBTITLE_INPUTS,
VIDEO_INPUTS,
} from "@snapotter/shared";
import type React from "react";
import { lazy } from "react";
import type { Crop } from "react-image-crop";
@@ -868,6 +874,13 @@ const VignetteSettings = lazy(() =>
})),
);
// One settings component shared by every conversion preset (jpg-to-png, etc.).
const ConversionPresetSettings = lazy(() =>
import("@/components/tools/conversion-preset-settings").then((m) => ({
default: m.ConversionPresetSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
@@ -1119,8 +1132,20 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["extract-zip", { accept: ".zip", Settings: ExtractZipSettings }],
];
// Conversion presets all share ConversionPresetSettings; each narrows the
// file picker to its own source inputs. Generated from shared metadata so the
// list stays in lockstep with the catalog.
const PRESET_ENTRIES: ReadonlyArray<[string, RegistryEntryConfig]> = CONVERSION_PRESETS.map(
(p) => [p.id, { accept: p.sourceInputs.join(","), Settings: ConversionPresetSettings }] as const,
);
const ALL_ENTRIES: ReadonlyArray<[string, RegistryEntryConfig]> = [
...ENTRY_CONFIG,
...PRESET_ENTRIES,
];
export const toolRegistry = new Map<string, ToolRegistryEntry>(
ENTRY_CONFIG.map(([toolId, entry]) => {
ALL_ENTRIES.map(([toolId, entry]) => {
const displayMode = TOOL_DISPLAY_MODES[toolId];
if (!displayMode) {
throw new Error(`Tool "${toolId}" has no display mode in tool-display-modes.ts`);
+12 -1
View File
@@ -1,4 +1,4 @@
import { modalityForExtension } from "@snapotter/shared";
import { modalityForExtension, type PipelineTemplate } from "@snapotter/shared";
import {
CheckCircle2,
ChevronDown,
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { TemplatesSection } from "@/components/automate/templates-section";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { Dropzone } from "@/components/common/dropzone";
import { FileLibraryModal } from "@/components/common/file-library-modal";
@@ -277,6 +278,13 @@ export function AutomatePage() {
[loadSteps],
);
const handleUseTemplate = useCallback(
(template: PipelineTemplate) => {
loadSteps(template.steps);
},
[loadSteps],
);
const handleExportPipeline = useCallback((pipeline: SavedPipeline) => {
const exportData = {
format: "snapotter-pipeline" as const,
@@ -739,6 +747,9 @@ export function AutomatePage() {
{/* Tool catalog */}
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
{/* Pipeline templates */}
<TemplatesSection onUse={handleUseTemplate} emphasized={steps.length === 0} />
{/* Saved pipelines */}
<div className="px-3 py-2 border-t border-border shrink-0">
<div className="flex items-center justify-between mb-1.5">
+13 -2
View File
@@ -89,7 +89,7 @@ export function HomePage() {
if (!search || search.length < 2) return;
const timer = setTimeout(() => {
import("@/lib/analytics").then(({ track }) => {
track(ANALYTICS_EVENTS.SEARCH, { query: search, results_count: searchResults.length });
track(ANALYTICS_EVENTS.SEARCH, { results_count: searchResults.length });
});
}, 1000);
return () => clearTimeout(timer);
@@ -195,6 +195,14 @@ function HomeSearchBar({
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
// Escape steps out of the search field: clear a query if present,
// otherwise drop focus back to the page.
if (e.key === "Escape") {
if (value) onChange("");
e.currentTarget.blur();
}
}}
placeholder={placeholder}
aria-label={placeholder}
className="w-full ps-11 pe-20 py-2.5 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-shadow"
@@ -244,7 +252,10 @@ function ModalityTabs({
className={cn(
"px-3.5 py-1.5 rounded-full text-sm font-medium transition-colors whitespace-nowrap",
activeTab === tab.key
? "bg-primary text-primary-foreground shadow-sm"
? // Fixed dark text on the orange pill (the orange is the same
// in both themes). White on Otter Orange is only ~3:1, below
// WCAG AA for this 14px label; near-black reaches ~5.8:1.
"bg-primary text-[#1a1814] shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
+5 -3
View File
@@ -43,9 +43,11 @@ export function PrivacyPolicyPage() {
<p>
SnapOtter includes basic analytics (tool usage, error reports) to help improve the
software. Your files, file names, and personal data are never part of this. Analytics
can be disabled by rebuilding with{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">SNAPOTTER_ANALYTICS=off</code>{" "}
-- everything works normally without it.
is on by default and can be disabled at runtime. An administrator can turn anonymous
product analytics off under Settings {">"} System {">"} Privacy, with no restart or
rebuild needed. For a compile-time hard-off, build with{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">SNAPOTTER_ANALYTICS=off</code>,
which strips it from the bundle entirely. Everything works normally without it.
</p>
</section>
+3 -2
View File
@@ -7,11 +7,12 @@ interface AnalyticsState {
fetchConfig: () => Promise<void>;
}
export const useAnalyticsStore = create<AnalyticsState>((set, get) => ({
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
config: null,
configLoaded: false,
// No one-shot guard: callers may refetch (e.g. on tab focus) so an
// instance-wide opt-out converges in already-open tabs.
fetchConfig: async () => {
if (get().configLoaded) return;
try {
const res = await fetch("/api/v1/config/analytics");
const config: AnalyticsConfig = await res.json();
+37 -20
View File
@@ -16,6 +16,12 @@ interface SettingsState {
const VALID_THEMES = new Set(["light", "dark", "system"]);
// De-duplicate concurrent fetches. Several components (AppLayout, the home grid,
// the connection monitor) call fetch() on mount; without this guard they would
// each fire a /v1/settings request before `loaded` flips, inflating the initial
// network load.
let inFlight: Promise<void> | null = null;
export const useSettingsStore = create<SettingsState>((set, get) => ({
disabledTools: [],
experimentalEnabled: false,
@@ -26,27 +32,38 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
fetch: async () => {
if (get().loaded && !get().loadError) return;
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const data = await apiGet<{
settings: Record<string, string>;
}>("/v1/settings");
const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme)
? (data.settings.defaultTheme as Theme)
: "light";
set({
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
experimentalEnabled: data.settings.enableExperimentalTools === "true",
defaultToolView:
data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
defaultTheme,
loaded: true,
loadError: false,
});
useThemeStore.getState().applyServerDefault(defaultTheme);
} catch {
set({ loaded: true, loadError: true });
}
})();
try {
const data = await apiGet<{
settings: Record<string, string>;
}>("/v1/settings");
const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme)
? (data.settings.defaultTheme as Theme)
: "light";
set({
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
experimentalEnabled: data.settings.enableExperimentalTools === "true",
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
defaultTheme,
loaded: true,
loadError: false,
});
useThemeStore.getState().applyServerDefault(defaultTheme);
} catch {
set({ loaded: true, loadError: true });
await inFlight;
} finally {
inFlight = null;
}
},
}));