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:
Siddharth Kumar Sah
2026-04-15 18:52:36 +08:00
parent 82073bba68
commit a4c63855d4
15 changed files with 295 additions and 133 deletions
+2
View File
@@ -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
View File
@@ -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}
+3 -3
View File
@@ -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,
+106
View File
@@ -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,
};
+5 -8
View File
@@ -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}
+6 -11
View File
@@ -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}
+10 -4
View File
@@ -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;
+3
View File
@@ -17,4 +17,7 @@ export default defineConfig({
"/api": "http://localhost:13490", "/api": "http://localhost:13490",
}, },
}, },
build: {
rollupOptions: {},
},
}); });
+7 -5
View File
@@ -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"]
+6
View File
@@ -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
+1 -1
View File
@@ -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.