mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: lightweight Docker image without AI/ML tools (:lite tag)
Closes #1
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { AutomatePage } from "./pages/automate-page";
|
||||
@@ -94,6 +95,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Toaster position="bottom-right" />
|
||||
<BrowserRouter>
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
import type { Tool } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { FileImage, Star } from "lucide-react";
|
||||
import { FileImage, Sparkles, Star } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: Tool;
|
||||
variantUnavailable?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCard({ tool }: ToolCardProps) {
|
||||
export function ToolCard({ tool, variantUnavailable }: ToolCardProps) {
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
const IconComponent = iconsMap[tool.icon] || FileImage;
|
||||
|
||||
if (variantUnavailable) {
|
||||
return (
|
||||
<div className="group flex items-center gap-3 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
|
||||
title="Add to favourites"
|
||||
>
|
||||
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast("This tool requires the full image.", {
|
||||
description:
|
||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
|
||||
>
|
||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
AI
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 relative">
|
||||
<button
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SearchBar } from "../common/search-bar";
|
||||
import { ToolCard } from "../common/tool-card";
|
||||
|
||||
export function ToolPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
||||
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
||||
const { disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
|
||||
useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => {
|
||||
setDisabledTools(
|
||||
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
);
|
||||
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
||||
|
||||
const visibleTools = useMemo(() => {
|
||||
if (!loaded) return [];
|
||||
return TOOLS.filter((t) => {
|
||||
if (disabledTools.includes(t.id)) return false;
|
||||
if (t.experimental && !experimentalEnabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [disabledTools, experimentalEnabled]);
|
||||
}, [disabledTools, experimentalEnabled, loaded]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!search) return visibleTools;
|
||||
@@ -59,7 +55,11 @@ export function ToolPanel() {
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupedTools.get(category.id)?.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
variantUnavailable={unavailableSet.has(tool.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -29,13 +30,7 @@ const IDLE_PROGRESS: ToolProgress = {
|
||||
|
||||
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
||||
const AI_PYTHON_TOOLS = new Set([
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
]);
|
||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
const {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
// Tools shown prominently as "quick actions" at the top
|
||||
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||
@@ -15,6 +17,13 @@ export function HomePage() {
|
||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
||||
useFileStore();
|
||||
const navigate = useNavigate();
|
||||
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
@@ -24,8 +33,22 @@ export function HomePage() {
|
||||
[setFiles, reset],
|
||||
);
|
||||
|
||||
const handleToolClick = (route: string) => {
|
||||
// Files are already in the store — just navigate
|
||||
const handleToolClick = (route: string, toolId: string) => {
|
||||
if (unavailableSet.has(toolId)) {
|
||||
toast("This tool requires the full image.", {
|
||||
description:
|
||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(route);
|
||||
};
|
||||
|
||||
@@ -80,8 +103,11 @@ export function HomePage() {
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleToolClick(tool.route)}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
|
||||
unavailableSet.has(id) && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -122,10 +148,12 @@ export function HomePage() {
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => handleToolClick(tool.route)}
|
||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
|
||||
"hover:bg-muted text-foreground",
|
||||
unavailableSet.has(tool.id)
|
||||
? "opacity-50 hover:bg-muted/50"
|
||||
: "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { apiGet } from "@/lib/api";
|
||||
|
||||
interface SettingsState {
|
||||
variant: "full" | "lite";
|
||||
variantUnavailableTools: string[];
|
||||
disabledTools: string[];
|
||||
experimentalEnabled: boolean;
|
||||
loaded: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
variant: "full",
|
||||
variantUnavailableTools: [],
|
||||
disabledTools: [],
|
||||
experimentalEnabled: false,
|
||||
loaded: false,
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
settings: Record<string, string>;
|
||||
variant: "full" | "lite";
|
||||
variantUnavailableTools: string[];
|
||||
}>("/v1/settings");
|
||||
|
||||
set({
|
||||
variant: data.variant ?? "full",
|
||||
variantUnavailableTools: data.variantUnavailableTools ?? [],
|
||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
// Settings fetch failed - default to full with no disabled tools
|
||||
set({ loaded: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user