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:
+23
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user