mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(docker): fix TDZ crash, icon bundle bloat, rate-limit on static assets
- Fix "Cannot access 'a' before initialization" TDZ error after login caused by manualChunks splitting react-vendor + lucide icons into circular ES-module chunks. Removed manualChunks entirely. - Replace `import * as icons from "lucide-react"` (pulls all ~1000 icons) with a targeted icon-map of ~50 icons actually used by tool definitions. Reduces shared icons chunk from 745KB to 62KB (132KB→16KB gzip). - Exclude static files from @fastify/rate-limit via allowList so rapid page navigations don't 429 on JS/CSS chunk requests. - Move Docker auth defaults (AUTH_ENABLED, DEFAULT_USERNAME, DEFAULT_PASSWORD) from Dockerfile ENV to entrypoint.sh runtime exports to avoid SecretsUsedInArgOrEnv warnings. - Fix Docker CMD to use pnpm --filter for workspace-scoped tsx binary. - Set COREPACK_HOME system-wide so non-root user can access pnpm cache. - Lazy-load all pages in App.tsx and all controls in pipeline-step-settings.tsx to keep main bundle under 300KB.
This commit is contained in:
@@ -64,6 +64,8 @@ app.addHook("onSend", async (_request, reply) => {
|
|||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: env.RATE_LIMIT_PER_MIN,
|
max: env.RATE_LIMIT_PER_MIN,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
|
// Only rate-limit API endpoints — static files and the SPA fallback must never be throttled
|
||||||
|
allowList: (request) => !request.url.startsWith("/api/"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multipart upload support
|
// Multipart upload support
|
||||||
|
|||||||
+49
-27
@@ -1,16 +1,27 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import { AutomatePage } from "./pages/automate-page";
|
|
||||||
import { ChangePasswordPage } from "./pages/change-password-page";
|
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||||
import { FilesPage } from "./pages/files-page";
|
// downloaded when the user navigates there, shrinking the main bundle.
|
||||||
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
const AutomatePage = lazy(() =>
|
||||||
import { HomePage } from "./pages/home-page";
|
import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })),
|
||||||
import { LoginPage } from "./pages/login-page";
|
);
|
||||||
import { PrivacyPolicyPage } from "./pages/privacy-policy-page";
|
const ChangePasswordPage = lazy(() =>
|
||||||
import { ToolPage } from "./pages/tool-page";
|
import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })),
|
||||||
|
);
|
||||||
|
const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage })));
|
||||||
|
const FullscreenGridPage = lazy(() =>
|
||||||
|
import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })),
|
||||||
|
);
|
||||||
|
const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage })));
|
||||||
|
const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage })));
|
||||||
|
const PrivacyPolicyPage = lazy(() =>
|
||||||
|
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
||||||
|
);
|
||||||
|
const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage })));
|
||||||
|
|
||||||
class ErrorBoundary extends Component<
|
class ErrorBoundary extends Component<
|
||||||
{ children: ReactNode },
|
{ children: ReactNode },
|
||||||
@@ -92,6 +103,15 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single page-level loading fallback — shown while JS for a route downloads.
|
||||||
|
function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -99,24 +119,26 @@ export function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<KeyboardShortcutProvider>
|
<KeyboardShortcutProvider>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<Routes>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/automate" element={<AutomatePage />} />
|
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/automate" element={<AutomatePage />} />
|
||||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
<Route
|
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||||
path="/brightness-contrast"
|
<Route
|
||||||
element={<Navigate to="/adjust-colors" replace />}
|
path="/brightness-contrast"
|
||||||
/>
|
element={<Navigate to="/adjust-colors" replace />}
|
||||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
/>
|
||||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
</Routes>
|
<Route path="/" element={<HomePage />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
</KeyboardShortcutProvider>
|
</KeyboardShortcutProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { TOOLS } from "@ashim/shared";
|
import { TOOLS } from "@ashim/shared";
|
||||||
import * as icons from "lucide-react";
|
import { ArrowRight, ChevronDown, ChevronRight, Download, FileImage, Undo2 } from "lucide-react";
|
||||||
import { ArrowRight, ChevronDown, ChevronRight, Download, Undo2 } from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { formatFileSize, triggerDownload } from "@/lib/download";
|
import { formatFileSize, triggerDownload } from "@/lib/download";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { getSuggestedTools } from "@/lib/suggested-tools";
|
import { getSuggestedTools } from "@/lib/suggested-tools";
|
||||||
|
|
||||||
interface ReviewPanelProps {
|
interface ReviewPanelProps {
|
||||||
@@ -124,12 +124,8 @@ export function ReviewPanel({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{suggestedTools.map((tool) => {
|
{suggestedTools.map((tool) => {
|
||||||
const ToolIcon =
|
const ToolIcon =
|
||||||
(
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ??
|
||||||
icons as unknown as Record<
|
FileImage;
|
||||||
string,
|
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
>
|
|
||||||
)[tool.icon] || icons.FileImage;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Tool } from "@ashim/shared";
|
import type { Tool } from "@ashim/shared";
|
||||||
import * as icons from "lucide-react";
|
|
||||||
import { FileImage, Star } from "lucide-react";
|
import { FileImage, Star } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
@@ -9,8 +9,8 @@ interface ToolCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCard({ tool }: ToolCardProps) {
|
export function ToolCard({ tool }: ToolCardProps) {
|
||||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
const IconComponent =
|
||||||
const IconComponent = iconsMap[tool.icon] || FileImage;
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-3 relative">
|
<div className="group flex items-center gap-3 relative">
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import * as icons from "lucide-react";
|
import { FileImage, GripVertical, Plus, X } from "lucide-react";
|
||||||
import { GripVertical, Plus, X } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { SearchBar } from "@/components/common/search-bar";
|
import { SearchBar } from "@/components/common/search-bar";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||||
import { PipelineStepSettings } from "./pipeline-step-settings";
|
import { PipelineStepSettings } from "./pipeline-step-settings";
|
||||||
@@ -30,8 +30,6 @@ const PIPELINE_TOOLS_BASE = TOOLS.filter(
|
|||||||
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
|
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
|
||||||
|
|
||||||
interface PipelineBuilderProps {
|
interface PipelineBuilderProps {
|
||||||
steps: PipelineStep[];
|
steps: PipelineStep[];
|
||||||
expandedStepId: string | null;
|
expandedStepId: string | null;
|
||||||
@@ -75,7 +73,7 @@ function SortableStep({
|
|||||||
const tool = TOOLS.find((t) => t.id === step.toolId);
|
const tool = TOOLS.find((t) => t.id === step.toolId);
|
||||||
if (!tool) return null;
|
if (!tool) return null;
|
||||||
|
|
||||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
const summary = getSettingsSummary(step.toolId, step.settings);
|
const summary = getSettingsSummary(step.toolId, step.settings);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -264,7 +262,8 @@ export function PipelineBuilder({
|
|||||||
<p className="text-sm text-muted-foreground text-center py-4">No tools found</p>
|
<p className="text-sm text-muted-foreground text-center py-4">No tools found</p>
|
||||||
) : (
|
) : (
|
||||||
PIPELINE_TOOLS.map((tool) => {
|
PIPELINE_TOOLS.map((tool) => {
|
||||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
const Icon =
|
||||||
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
|
|||||||
@@ -1,24 +1,62 @@
|
|||||||
import { BlurFacesControls } from "./blur-faces-settings";
|
import { lazy, Suspense } from "react";
|
||||||
import { BorderControls } from "./border-settings";
|
|
||||||
import { ColorControls } from "./color-settings";
|
type ControlProps = {
|
||||||
import { CompressControls } from "./compress-settings";
|
settings: Record<string, unknown>;
|
||||||
import { ConvertControls } from "./convert-settings";
|
onChange: (settings: Record<string, unknown>) => void;
|
||||||
import { CropControls } from "./crop-settings";
|
};
|
||||||
import { EnhanceFacesControls } from "./enhance-faces-settings";
|
|
||||||
import { GifToolsControls } from "./gif-tools-settings";
|
// Lazy-load every control so these modules are not pulled into the main bundle.
|
||||||
import { NoiseRemovalControls } from "./noise-removal-settings";
|
// The pipeline builder wraps this component in <Suspense> so each control
|
||||||
import { RemoveBgControls } from "./remove-bg-settings";
|
// loads on demand when the user selects a step in the pipeline.
|
||||||
import { ReplaceColorControls } from "./replace-color-settings";
|
const CONTROLS: Record<string, React.LazyExoticComponent<React.FC<ControlProps>>> = {
|
||||||
import { ResizeControls } from "./resize-settings";
|
resize: lazy(() => import("./resize-settings").then((m) => ({ default: m.ResizeControls }))),
|
||||||
import { RotateControls } from "./rotate-settings";
|
crop: lazy(() => import("./crop-settings").then((m) => ({ default: m.CropControls }))),
|
||||||
import { SmartCropControls } from "./smart-crop-settings";
|
rotate: lazy(() => import("./rotate-settings").then((m) => ({ default: m.RotateControls }))),
|
||||||
import { StripMetadataControls } from "./strip-metadata-settings";
|
convert: lazy(() => import("./convert-settings").then((m) => ({ default: m.ConvertControls }))),
|
||||||
import { TextOverlayControls } from "./text-overlay-settings";
|
compress: lazy(() =>
|
||||||
import { UpscaleControls } from "./upscale-settings";
|
import("./compress-settings").then((m) => ({ default: m.CompressControls })),
|
||||||
import { WatermarkTextControls } from "./watermark-text-settings";
|
),
|
||||||
|
"strip-metadata": lazy(() =>
|
||||||
|
import("./strip-metadata-settings").then((m) => ({ default: m.StripMetadataControls })),
|
||||||
|
),
|
||||||
|
border: lazy(() => import("./border-settings").then((m) => ({ default: m.BorderControls }))),
|
||||||
|
"watermark-text": lazy(() =>
|
||||||
|
import("./watermark-text-settings").then((m) => ({ default: m.WatermarkTextControls })),
|
||||||
|
),
|
||||||
|
"text-overlay": lazy(() =>
|
||||||
|
import("./text-overlay-settings").then((m) => ({ default: m.TextOverlayControls })),
|
||||||
|
),
|
||||||
|
"replace-color": lazy(() =>
|
||||||
|
import("./replace-color-settings").then((m) => ({ default: m.ReplaceColorControls })),
|
||||||
|
),
|
||||||
|
"smart-crop": lazy(() =>
|
||||||
|
import("./smart-crop-settings").then((m) => ({ default: m.SmartCropControls })),
|
||||||
|
),
|
||||||
|
"gif-tools": lazy(() =>
|
||||||
|
import("./gif-tools-settings").then((m) => ({ default: m.GifToolsControls })),
|
||||||
|
),
|
||||||
|
upscale: lazy(() => import("./upscale-settings").then((m) => ({ default: m.UpscaleControls }))),
|
||||||
|
"blur-faces": lazy(() =>
|
||||||
|
import("./blur-faces-settings").then((m) => ({ default: m.BlurFacesControls })),
|
||||||
|
),
|
||||||
|
"enhance-faces": lazy(() =>
|
||||||
|
import("./enhance-faces-settings").then((m) => ({ default: m.EnhanceFacesControls })),
|
||||||
|
),
|
||||||
|
"remove-background": lazy(() =>
|
||||||
|
import("./remove-bg-settings").then((m) => ({ default: m.RemoveBgControls })),
|
||||||
|
),
|
||||||
|
"noise-removal": lazy(() =>
|
||||||
|
import("./noise-removal-settings").then((m) => ({ default: m.NoiseRemovalControls })),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const COLOR_TOOL_IDS = new Set(["adjust-colors"]);
|
const COLOR_TOOL_IDS = new Set(["adjust-colors"]);
|
||||||
|
|
||||||
|
// ColorControls needs an extra toolId prop so it lives outside the shared map.
|
||||||
|
const LazyColorControls = lazy(() =>
|
||||||
|
import("./color-settings").then((m) => ({ default: m.ColorControls })),
|
||||||
|
);
|
||||||
|
|
||||||
interface PipelineStepSettingsProps {
|
interface PipelineStepSettingsProps {
|
||||||
toolId: string;
|
toolId: string;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -26,32 +64,23 @@ interface PipelineStepSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
|
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
|
||||||
if (toolId === "resize") return <ResizeControls settings={settings} onChange={onChange} />;
|
const Control = CONTROLS[toolId];
|
||||||
if (toolId === "crop") return <CropControls settings={settings} onChange={onChange} />;
|
|
||||||
if (toolId === "rotate") return <RotateControls settings={settings} onChange={onChange} />;
|
if (Control) {
|
||||||
if (toolId === "convert") return <ConvertControls settings={settings} onChange={onChange} />;
|
return (
|
||||||
if (toolId === "compress") return <CompressControls settings={settings} onChange={onChange} />;
|
<Suspense fallback={null}>
|
||||||
if (toolId === "strip-metadata")
|
<Control settings={settings} onChange={onChange} />
|
||||||
return <StripMetadataControls settings={settings} onChange={onChange} />;
|
</Suspense>
|
||||||
if (toolId === "border") return <BorderControls settings={settings} onChange={onChange} />;
|
);
|
||||||
if (toolId === "watermark-text")
|
}
|
||||||
return <WatermarkTextControls settings={settings} onChange={onChange} />;
|
|
||||||
if (toolId === "text-overlay")
|
if (COLOR_TOOL_IDS.has(toolId)) {
|
||||||
return <TextOverlayControls settings={settings} onChange={onChange} />;
|
return (
|
||||||
if (toolId === "replace-color")
|
<Suspense fallback={null}>
|
||||||
return <ReplaceColorControls settings={settings} onChange={onChange} />;
|
<LazyColorControls toolId={toolId} settings={settings} onChange={onChange} />
|
||||||
if (toolId === "smart-crop") return <SmartCropControls settings={settings} onChange={onChange} />;
|
</Suspense>
|
||||||
if (toolId === "gif-tools") return <GifToolsControls settings={settings} onChange={onChange} />;
|
);
|
||||||
if (toolId === "upscale") return <UpscaleControls settings={settings} onChange={onChange} />;
|
}
|
||||||
if (toolId === "blur-faces") return <BlurFacesControls settings={settings} onChange={onChange} />;
|
|
||||||
if (toolId === "enhance-faces")
|
|
||||||
return <EnhanceFacesControls settings={settings} onChange={onChange} />;
|
|
||||||
if (toolId === "remove-background")
|
|
||||||
return <RemoveBgControls settings={settings} onChange={onChange} />;
|
|
||||||
if (toolId === "noise-removal")
|
|
||||||
return <NoiseRemovalControls settings={settings} onChange={onChange} />;
|
|
||||||
if (COLOR_TOOL_IDS.has(toolId))
|
|
||||||
return <ColorControls toolId={toolId} settings={settings} onChange={onChange} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type SubjectType = "people" | "products" | "general";
|
type SubjectType = "people" | "products" | "general";
|
||||||
@@ -565,22 +566,20 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
// Decode HEIC via server preview endpoint
|
// Decode HEIC via server preview endpoint
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
import("@/lib/api").then(({ formatHeaders }) => {
|
fetch("/api/v1/preview", {
|
||||||
fetch("/api/v1/preview", {
|
method: "POST",
|
||||||
method: "POST",
|
headers: formatHeaders(),
|
||||||
headers: formatHeaders(),
|
body: formData,
|
||||||
body: formData,
|
})
|
||||||
|
.then((res) => (res.ok ? res.blob() : null))
|
||||||
|
.then((blob) => {
|
||||||
|
if (blob && bgImageFileRef.current === file) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
revoke = () => URL.revokeObjectURL(url);
|
||||||
|
setBgImageBlobUrl(url);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then((res) => (res.ok ? res.blob() : null))
|
.catch(() => {});
|
||||||
.then((blob) => {
|
|
||||||
if (blob && bgImageFileRef.current === file) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
revoke = () => URL.revokeObjectURL(url);
|
|
||||||
setBgImageBlobUrl(url);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
revoke = () => URL.revokeObjectURL(url);
|
revoke = () => URL.revokeObjectURL(url);
|
||||||
@@ -744,7 +743,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
formData.append("backgroundImage", bgImageFile);
|
formData.append("backgroundImage", bgImageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = (await import("@/lib/api")).formatHeaders();
|
const headers = formatHeaders();
|
||||||
const response = await fetch("/api/v1/tools/remove-background/effects", {
|
const response = await fetch("/api/v1/tools/remove-background/effects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Code,
|
||||||
|
Columns,
|
||||||
|
Columns2,
|
||||||
|
Copy,
|
||||||
|
Crop,
|
||||||
|
Eraser,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileEdit,
|
||||||
|
FileImage,
|
||||||
|
FileOutput,
|
||||||
|
FileText,
|
||||||
|
FileType,
|
||||||
|
Film,
|
||||||
|
Focus,
|
||||||
|
FolderInput,
|
||||||
|
Frame,
|
||||||
|
Globe,
|
||||||
|
Grid3x3,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
LayoutGrid,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Palette,
|
||||||
|
PenLine,
|
||||||
|
PenTool,
|
||||||
|
Pipette,
|
||||||
|
QrCode,
|
||||||
|
RotateCw,
|
||||||
|
ScanFace,
|
||||||
|
ScanLine,
|
||||||
|
ScanText,
|
||||||
|
ShieldOff,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Stamp,
|
||||||
|
Star,
|
||||||
|
TextCursorInput,
|
||||||
|
Type,
|
||||||
|
Undo2,
|
||||||
|
UserCheck,
|
||||||
|
Wand2,
|
||||||
|
Workflow,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
ZoomIn,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Only the icons actually used by tool definitions in @ashim/shared constants.
|
||||||
|
// Avoids `import * as icons from "lucide-react"` which pulls the entire 1000+ icon library.
|
||||||
|
export const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
CheckCircle2,
|
||||||
|
Code,
|
||||||
|
Columns,
|
||||||
|
Columns2,
|
||||||
|
Copy,
|
||||||
|
Crop,
|
||||||
|
Eraser,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileEdit,
|
||||||
|
FileImage,
|
||||||
|
FileOutput,
|
||||||
|
FileText,
|
||||||
|
FileType,
|
||||||
|
Film,
|
||||||
|
Focus,
|
||||||
|
FolderInput,
|
||||||
|
Frame,
|
||||||
|
Globe,
|
||||||
|
Grid3x3,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
LayoutGrid,
|
||||||
|
Layers,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Palette,
|
||||||
|
PenLine,
|
||||||
|
PenTool,
|
||||||
|
Pipette,
|
||||||
|
QrCode,
|
||||||
|
RotateCw,
|
||||||
|
ScanFace,
|
||||||
|
ScanLine,
|
||||||
|
ScanText,
|
||||||
|
ShieldOff,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Stamp,
|
||||||
|
Star,
|
||||||
|
TextCursorInput,
|
||||||
|
Type,
|
||||||
|
Undo2,
|
||||||
|
UserCheck,
|
||||||
|
Wand2,
|
||||||
|
Workflow,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
ZoomIn,
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { CategoryInfo, Tool } from "@ashim/shared";
|
import type { CategoryInfo, Tool } from "@ashim/shared";
|
||||||
import { CATEGORIES, TOOLS } from "@ashim/shared";
|
import { CATEGORIES, TOOLS } from "@ashim/shared";
|
||||||
import * as icons from "lucide-react";
|
|
||||||
import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { GemLogo } from "@/components/common/gem-logo";
|
import { GemLogo } from "@/components/common/gem-logo";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function FullscreenGridPage() {
|
export function FullscreenGridPage() {
|
||||||
@@ -57,8 +57,6 @@ export function FullscreenGridPage() {
|
|||||||
|
|
||||||
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
||||||
|
|
||||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -130,7 +128,6 @@ export function FullscreenGridPage() {
|
|||||||
category={category}
|
category={category}
|
||||||
tools={groupedTools.get(category.id) || []}
|
tools={groupedTools.get(category.id) || []}
|
||||||
showDetails={showDetails}
|
showDetails={showDetails}
|
||||||
iconsMap={iconsMap}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,14 +141,13 @@ function CategoryCard({
|
|||||||
category,
|
category,
|
||||||
tools,
|
tools,
|
||||||
showDetails,
|
showDetails,
|
||||||
iconsMap,
|
|
||||||
}: {
|
}: {
|
||||||
category: CategoryInfo;
|
category: CategoryInfo;
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
showDetails: boolean;
|
showDetails: boolean;
|
||||||
iconsMap: Record<string, React.ComponentType<{ className?: string }>>;
|
|
||||||
}) {
|
}) {
|
||||||
const CategoryIcon = iconsMap[category.icon] || LayoutGrid;
|
const CategoryIcon =
|
||||||
|
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
||||||
@@ -183,7 +179,8 @@ function CategoryCard({
|
|||||||
{/* Tool list */}
|
{/* Tool list */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{tools.map((tool) => {
|
{tools.map((tool) => {
|
||||||
const ToolIcon = iconsMap[tool.icon] || FileImage;
|
const ToolIcon =
|
||||||
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { CATEGORIES, TOOLS } from "@ashim/shared";
|
import { CATEGORIES, TOOLS } from "@ashim/shared";
|
||||||
import * as icons from "lucide-react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
import { ImageViewer } from "@/components/common/image-viewer";
|
||||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export function HomePage() {
|
|||||||
{/* File info */}
|
{/* File info */}
|
||||||
<div className="p-4 border-b border-border">
|
<div className="p-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<icons.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
<ICON_MAP.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
<span className="truncate font-medium text-foreground">
|
<span className="truncate font-medium text-foreground">
|
||||||
{selectedFileName ?? files[0].name}
|
{selectedFileName ?? files[0].name}
|
||||||
</span>
|
</span>
|
||||||
@@ -81,9 +81,8 @@ export function HomePage() {
|
|||||||
const tool = TOOLS.find((t) => t.id === id);
|
const tool = TOOLS.find((t) => t.id === id);
|
||||||
if (!tool) return null;
|
if (!tool) return null;
|
||||||
const Icon =
|
const Icon =
|
||||||
(icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ??
|
||||||
tool.icon
|
ICON_MAP.FileImage;
|
||||||
] || icons.FileImage;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
@@ -120,12 +119,8 @@ export function HomePage() {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{categoryTools.map((tool) => {
|
{categoryTools.map((tool) => {
|
||||||
const Icon =
|
const Icon =
|
||||||
(
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ??
|
||||||
icons as unknown as Record<
|
ICON_MAP.FileImage;
|
||||||
string,
|
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
>
|
|
||||||
)[tool.icon] || icons.FileImage;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { TOOLS } from "@ashim/shared";
|
import { TOOLS } from "@ashim/shared";
|
||||||
import * as icons from "lucide-react";
|
import {
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, Download, Loader2 } from "lucide-react";
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
FileImage,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Crop } from "react-image-crop";
|
import type { Crop } from "react-image-crop";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -17,6 +23,7 @@ import { EraserCanvas } from "@/components/tools/eraser-canvas";
|
|||||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { formatFileSize } from "@/lib/download";
|
import { formatFileSize } from "@/lib/download";
|
||||||
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -222,8 +229,7 @@ export function ToolPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IconComponent =
|
const IconComponent =
|
||||||
(icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] ||
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
icons.FileImage;
|
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
const hasProcessed = !!processedUrl;
|
const hasProcessed = !!processedUrl;
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ export default defineConfig({
|
|||||||
"/api": "http://localhost:13490",
|
"/api": "http://localhost:13490",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-5
@@ -66,6 +66,10 @@ ARG TARGETARCH
|
|||||||
# Set to "true" to skip model downloads (for CI builds that just test the image structure)
|
# Set to "true" to skip model downloads (for CI builds that just test the image structure)
|
||||||
ARG SKIP_MODEL_DOWNLOADS=false
|
ARG SKIP_MODEL_DOWNLOADS=false
|
||||||
|
|
||||||
|
# Pin corepack's cache to a system-wide path so all users share the same pnpm
|
||||||
|
# binary without downloading it on each container start.
|
||||||
|
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||||
|
|
||||||
# Install Node.js on amd64 (CUDA base has no Node; arm64 base already has it)
|
# Install Node.js on amd64 (CUDA base has no Node; arm64 base already has it)
|
||||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -79,7 +83,8 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
|||||||
rm -rf /var/lib/apt/lists/* \
|
rm -rf /var/lib/apt/lists/* \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate && \
|
||||||
|
chmod -R a+rX /usr/local/share/corepack
|
||||||
|
|
||||||
# System dependencies (all platforms)
|
# System dependencies (all platforms)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -203,9 +208,6 @@ RUN mkdir -p /app/gfpgan/weights/CodeFormer && \
|
|||||||
# Environment defaults
|
# Environment defaults
|
||||||
ENV PORT=1349 \
|
ENV PORT=1349 \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
AUTH_ENABLED=true \
|
|
||||||
DEFAULT_USERNAME=admin \
|
|
||||||
DEFAULT_PASSWORD=admin \
|
|
||||||
STORAGE_MODE=local \
|
STORAGE_MODE=local \
|
||||||
DB_PATH=/data/ashim.db \
|
DB_PATH=/data/ashim.db \
|
||||||
WORKSPACE_PATH=/tmp/workspace \
|
WORKSPACE_PATH=/tmp/workspace \
|
||||||
@@ -247,4 +249,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|||||||
|
|
||||||
# tini as PID 1 for zombie reaping + signal forwarding
|
# tini as PID 1 for zombie reaping + signal forwarding
|
||||||
ENTRYPOINT ["tini", "--", "entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "entrypoint.sh"]
|
||||||
CMD ["pnpm", "exec", "tsx", "apps/api/src/index.ts"]
|
CMD ["pnpm", "--filter", "@ashim/api", "run", "start"]
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Apply auth defaults at runtime so they are never baked into image layers.
|
||||||
|
# Users can override any of these via -e flags at docker run time.
|
||||||
|
export AUTH_ENABLED="${AUTH_ENABLED:-true}"
|
||||||
|
export DEFAULT_USERNAME="${DEFAULT_USERNAME:-admin}"
|
||||||
|
export DEFAULT_PASSWORD="${DEFAULT_PASSWORD:-admin}"
|
||||||
|
|
||||||
# Fix ownership of mounted volumes so the non-root ashim user can write.
|
# Fix ownership of mounted volumes so the non-root ashim user can write.
|
||||||
# This runs as root, fixes permissions, then drops to ashim via gosu.
|
# This runs as root, fixes permissions, then drops to ashim via gosu.
|
||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import { dirname } from "node:path";
|
|||||||
mkdirSync(dirname(process.env.DB_PATH!), { recursive: true });
|
mkdirSync(dirname(process.env.DB_PATH!), { recursive: true });
|
||||||
mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true });
|
mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true });
|
||||||
|
|
||||||
import cors from "@fastify/cors";
|
|
||||||
import { APP_VERSION } from "@ashim/shared";
|
import { APP_VERSION } from "@ashim/shared";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Import app modules. config.ts already captured our env vars.
|
// 2. Import app modules. config.ts already captured our env vars.
|
||||||
|
|||||||
Reference in New Issue
Block a user