chore: remove swagger deps, parallelize CI jobs

- Remove @fastify/swagger and @fastify/swagger-ui (API docs live on GitHub Pages)
- Run typecheck, build, and docker CI jobs in parallel instead of sequentially
This commit is contained in:
Siddharth Kumar Sah
2026-03-24 00:41:54 +08:00
parent fbdbe0949a
commit 0aa2a5e5de
18 changed files with 360 additions and 337 deletions
-2
View File
@@ -30,7 +30,6 @@ jobs:
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: typecheck
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -47,7 +46,6 @@ jobs:
docker: docker:
name: Docker Build Test name: Docker Build Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-2
View File
@@ -15,8 +15,6 @@
"@fastify/multipart": "^9.0.0", "@fastify/multipart": "^9.0.0",
"@fastify/rate-limit": "^10.2.0", "@fastify/rate-limit": "^10.2.0",
"@fastify/static": "^8.1.0", "@fastify/static": "^8.1.0",
"@fastify/swagger": "^9.4.0",
"@fastify/swagger-ui": "^5.2.0",
"@stirling-image/ai": "workspace:*", "@stirling-image/ai": "workspace:*",
"@stirling-image/image-engine": "workspace:*", "@stirling-image/image-engine": "workspace:*",
"@stirling-image/shared": "workspace:*", "@stirling-image/shared": "workspace:*",
-21
View File
@@ -1,10 +1,7 @@
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import { env } from "./config.js"; import { env } from "./config.js";
import { APP_VERSION } from "@stirling-image/shared";
import { runMigrations } from "./db/migrate.js"; import { runMigrations } from "./db/migrate.js";
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js"; import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
import { registerUpload } from "./plugins/upload.js"; import { registerUpload } from "./plugins/upload.js";
@@ -51,24 +48,6 @@ await app.register(rateLimit, {
timeWindow: "1 minute", timeWindow: "1 minute",
}); });
// Swagger / OpenAPI documentation (dev only)
if (process.env.NODE_ENV !== "production") {
await app.register(swagger, {
openapi: {
info: {
title: "Stirling Image API",
description: "API for Stirling Image — self-hosted image processing suite",
version: APP_VERSION,
},
servers: [{ url: `http://localhost:1349` }],
},
});
await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
}
// Multipart upload support // Multipart upload support
await registerUpload(app); await registerUpload(app);
+62 -41
View File
@@ -117,17 +117,34 @@ export async function registerBatchRoutes(
}; };
updateJobProgress({ ...progress }); updateJobProgress({ ...progress });
// Set up response headers for ZIP streaming // Tell Fastify we're taking over the response — without this,
// Fastify's lifecycle hooks conflict with reply.raw.writeHead()
// and can throw unhandled errors that crash the process.
reply.hijack();
// Set up response headers for ZIP streaming.
// X-File-Order must be URI-encoded because filenames can contain
// spaces/special chars that are invalid in HTTP header values.
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`, "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
"X-Job-Id": jobId, "X-Job-Id": jobId,
"X-File-Order": files.map(f => f.filename).join(","), "X-File-Order": files.map(f => encodeURIComponent(f.filename)).join(","),
}); });
// Create ZIP archive that pipes directly to the response // Create ZIP archive that pipes directly to the response
const archive = archiver("zip", { zlib: { level: 5 } }); const archive = archiver("zip", { zlib: { level: 5 } });
// Handle archive-level errors to prevent unhandled exceptions
// that would crash the server process.
archive.on("error", (err) => {
request.log.error({ err }, "Archiver error during batch processing");
if (!reply.raw.writableEnded) {
reply.raw.end();
}
});
archive.pipe(reply.raw); archive.pipe(reply.raw);
// Use p-queue for concurrency control // Use p-queue for concurrency control
@@ -154,50 +171,54 @@ export async function registerBatchRoutes(
} }
// Process all files through the queue // Process all files through the queue
const tasks = files.map((file) => try {
queue.add(async () => { const tasks = files.map((file) =>
progress.currentFile = file.filename; queue.add(async () => {
updateJobProgress({ ...progress }); progress.currentFile = file.filename;
// Validate the image
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
progress.completedFiles++;
updateJobProgress({ ...progress }); updateJobProgress({ ...progress });
return;
}
try { // Validate the image
const result = await toolConfig.process( const validation = await validateImageBuffer(file.buffer);
file.buffer, if (!validation.valid) {
settings, progress.failedFiles++;
file.filename, progress.errors.push({
); filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
progress.completedFiles++;
updateJobProgress({ ...progress });
return;
}
const zipFilename = getUniqueName(result.filename); try {
archive.append(result.buffer, { name: zipFilename }); const result = await toolConfig.process(
file.buffer,
settings,
file.filename,
);
progress.completedFiles++; const zipFilename = getUniqueName(result.filename);
updateJobProgress({ ...progress }); archive.append(result.buffer, { name: zipFilename });
} catch (err) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: err instanceof Error ? err.message : "Processing failed",
});
progress.completedFiles++;
updateJobProgress({ ...progress });
}
}),
);
// Wait for all tasks to complete progress.completedFiles++;
await Promise.all(tasks); updateJobProgress({ ...progress });
} catch (err) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: err instanceof Error ? err.message : "Processing failed",
});
progress.completedFiles++;
updateJobProgress({ ...progress });
}
}),
);
// Wait for all tasks to complete
await Promise.all(tasks);
} catch (err) {
request.log.error({ err }, "Unexpected error in batch queue");
}
// Finalize progress // Finalize progress
progress.status = progress.status =
+3
View File
@@ -86,6 +86,9 @@ export async function registerProgressRoutes(
) => { ) => {
const { jobId } = request.params; const { jobId } = request.params;
// Take over the response from Fastify for SSE streaming
reply.hijack();
// Send SSE headers via the raw Node response // Send SSE headers via the raw Node response
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
+1
View File
@@ -65,6 +65,7 @@ export function registerBulkRename(app: FastifyInstance) {
try { try {
const jobId = randomUUID(); const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`, "Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
+1
View File
@@ -43,6 +43,7 @@ export function registerFavicon(app: FastifyInstance) {
try { try {
const jobId = randomUUID(); const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`, "Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
+1
View File
@@ -70,6 +70,7 @@ export function registerSplit(app: FastifyInstance) {
const jobId = randomUUID(); const jobId = randomUUID();
// Set up response headers for ZIP // Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`, "Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
@@ -79,15 +79,19 @@ export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssF
maxWidth: "100%", maxWidth: "100%",
maxHeight: "100%", maxHeight: "100%",
objectFit: "contain" as const, objectFit: "contain" as const,
...(previewTransform && { transform: previewTransform }), ...(previewTransform && {
transform: previewTransform,
transition: "transform 0.25s ease",
}),
} }
: { : {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`, transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center", transformOrigin: "center center",
...(previewTransform && { transition: "transform 0.25s ease" }),
}; };
return ( return (
<div className="flex flex-col w-full h-full max-w-3xl mx-auto"> <div className="flex flex-col w-full h-full max-w-3xl mx-auto min-h-0">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0"> <div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
<button <button
@@ -22,14 +22,14 @@ export function MultiImageViewer() {
const hasProcessed = !!currentEntry.processedUrl; const hasProcessed = !!currentEntry.processedUrl;
return ( return (
<div className="flex flex-col w-full h-full" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}> <div className="flex flex-col w-full h-full min-h-0" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div className="flex-1 relative flex items-center justify-center"> <div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && ( {hasMultiple && hasPrev && (
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image"> <button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</button> </button>
)} )}
<div className="w-full h-full"> <div className="w-full h-full min-h-0">
{hasProcessed ? ( {hasProcessed ? (
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} /> <BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
) : ( ) : (
@@ -27,15 +27,6 @@ export function SideBySideComparison({
? ((1 - afterSize / beforeSize) * 100).toFixed(1) ? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null; : null;
const checkerboard = {
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
};
return ( return (
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto"> <div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
{/* Side-by-side images */} {/* Side-by-side images */}
@@ -45,14 +36,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original Original
</span> </span>
<div <div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img <img
src={beforeSrc} src={beforeSrc}
alt="Original" alt="Original"
className="max-w-full max-h-full object-contain" className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false} draggable={false}
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
@@ -70,19 +58,16 @@ export function SideBySideComparison({
</div> </div>
</div> </div>
{/* Resized */} {/* Processed */}
<div className="flex-1 flex flex-col items-center gap-2"> <div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Resized Processed
</span> </span>
<div <div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img <img
src={afterSrc} src={afterSrc}
alt="Resized" alt="Processed"
className="max-w-full max-h-full object-contain" className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false} draggable={false}
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
@@ -22,7 +22,7 @@ export function ResizeSettings() {
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize"); useToolProcessor("resize");
const [tab, setTab] = useState<ResizeTab>("presets"); const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null); const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>(""); const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>(""); const [height, setHeight] = useState<string>("");
@@ -84,15 +84,15 @@ export function ResizeSettings() {
{/* Tab selector */} {/* Tab selector */}
<div> <div>
<div className="flex gap-1"> <div className="flex gap-1">
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}> <button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size Custom Size
</button> </button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}> <button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale Scale
</button> </button>
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
</div> </div>
</div> </div>
@@ -1,8 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useToolProcessor } from "@/hooks/use-tool-processor";
import { import {
Download,
RotateCcw, RotateCcw,
RotateCw, RotateCw,
FlipHorizontal, FlipHorizontal,
@@ -22,24 +21,42 @@ interface RotateSettingsProps {
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
const { files } = useFileStore(); const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("rotate"); useToolProcessor("rotate");
const [angle, setAngle] = useState(0); // Quick rotation in 90° steps: 0, 90, 180, 270
const [rotation, setRotation] = useState(0);
// Fine straighten adjustment: -45 to +45
const [straighten, setStraighten] = useState(0);
const [flipH, setFlipH] = useState(false); const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false); const [flipV, setFlipV] = useState(false);
const totalAngle = rotation + straighten;
// Emit preview transform on every change // Emit preview transform on every change
useEffect(() => { useEffect(() => {
onPreviewTransform?.({ rotate: angle, flipH, flipV }); onPreviewTransform?.({ rotate: totalAngle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]); }, [totalAngle, flipH, flipV, onPreviewTransform]);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360); // Reset controls after successful processing
const rotateRight = () => setAngle((a) => (a + 90) % 360); const prevProcessing = useRef(processing);
useEffect(() => {
if (prevProcessing.current && !processing && !error) {
setRotation(0);
setStraighten(0);
setFlipH(false);
setFlipV(false);
}
prevProcessing.current = processing;
}, [processing, error]);
const rotateLeft = () => setRotation((r) => r - 90);
const rotateRight = () => setRotation((r) => r + 90);
const handleProcess = () => { const handleProcess = () => {
const backendAngle = ((totalAngle % 360) + 360) % 360;
const settings = { const settings = {
angle, angle: backendAngle,
horizontal: flipH, horizontal: flipH,
vertical: flipV, vertical: flipV,
}; };
@@ -51,52 +68,78 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
}; };
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasChanges = angle !== 0 || flipH || flipV; const hasChanges = totalAngle !== 0 || flipH || flipV;
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (hasFile && hasChanges && !processing) handleProcess(); if (hasFile && hasChanges && !processing) handleProcess();
}; };
const handleReset = () => {
setRotation(0);
setStraighten(0);
setFlipH(false);
setFlipV(false);
};
// Display angle normalized to 0-359
const displayAngle = ((totalAngle % 360) + 360) % 360;
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate buttons */} {/* Quick rotate */}
<div> <div>
<label className="text-xs text-muted-foreground">Quick Rotate</label> <label className="text-xs text-muted-foreground">Rotate</label>
<div className="flex gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<button <button
type="button" type="button"
onClick={rotateLeft} onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm" className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° counter-clockwise"
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
90 Left Left
</button> </button>
<div className="px-3 py-1.5 rounded-md bg-background border border-border text-center min-w-[4rem]">
<span className="text-sm font-mono font-medium tabular-nums">
{displayAngle}°
</span>
</div>
<button <button
type="button" type="button"
onClick={rotateRight} onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm" className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° clockwise"
> >
Right
<RotateCw className="h-4 w-4" /> <RotateCw className="h-4 w-4" />
90 Right
</button> </button>
</div> </div>
</div> </div>
{/* Angle slider */} {/* Straighten */}
<div> <div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label> <label className="text-xs text-muted-foreground">Straighten</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span> <span className="text-xs font-mono tabular-nums text-muted-foreground">
{straighten > 0 ? "+" : ""}
{straighten}°
</span>
</div> </div>
<input <input
type="range" type="range"
min={0} min={-45}
max={360} max={45}
value={angle} step={0.5}
onChange={(e) => setAngle(Number(e.target.value))} value={straighten}
onChange={(e) => setStraighten(Number(e.target.value))}
className="w-full mt-1" className="w-full mt-1"
/> />
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>-45°</span>
<span>0°</span>
<span>+45°</span>
</div>
</div> </div>
{/* Flip buttons */} {/* Flip buttons */}
@@ -106,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<button <button
type="button" type="button"
onClick={() => setFlipH(!flipH)} onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${ className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipH flipH
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10" : "bg-muted text-muted-foreground hover:bg-primary/10"
@@ -118,7 +161,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<button <button
type="button" type="button"
onClick={() => setFlipV(!flipV)} onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${ className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipV flipV
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10" : "bg-muted text-muted-foreground hover:bg-primary/10"
@@ -130,6 +173,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</div> </div>
</div> </div>
{/* Reset all */}
{hasChanges && (
<button
type="button"
onClick={handleReset}
className="w-full text-xs text-muted-foreground hover:text-foreground py-1"
>
Reset all changes
</button>
)}
{/* Error */} {/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
@@ -152,18 +206,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{files.length > 1 ? `Apply (${files.length} files)` : "Apply"} {files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
</button> </button>
)} )}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
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" />
Download
</a>
)}
</form> </form>
); );
} }
+1 -1
View File
@@ -289,7 +289,7 @@ export function useToolProcessor(toolId: string) {
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer); const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
const extracted = unzipSync(zipBuffer); const extracted = unzipSync(zipBuffer);
const fileOrder = response.headers.get("X-File-Order")?.split(",") ?? []; const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(decodeURIComponent);
const entries = useFileStore.getState().entries; const entries = useFileStore.getState().entries;
const extractedNames = Object.keys(extracted); const extractedNames = Object.keys(extracted);
+5 -2
View File
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { ImageViewer } from "@/components/common/image-viewer"; import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { TOOLS, CATEGORIES } from "@stirling-image/shared"; import { TOOLS, CATEGORIES } from "@stirling-image/shared";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
@@ -124,8 +125,10 @@ export function HomePage() {
</div> </div>
{/* Right panel: Image preview */} {/* Right panel: Image preview */}
<div className="flex-1 flex items-center justify-center p-6"> <div className="flex-1 flex items-center justify-center p-6 min-h-0">
{originalBlobUrl ? ( {files.length > 1 ? (
<MultiImageViewer />
) : originalBlobUrl ? (
<ImageViewer <ImageViewer
src={originalBlobUrl} src={originalBlobUrl}
filename={selectedFileName ?? files[0].name} filename={selectedFileName ?? files[0].name}
+178 -117
View File
@@ -4,8 +4,8 @@ import type { Crop } from "react-image-crop";
import { TOOLS } from "@stirling-image/shared"; import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone"; import { Dropzone } from "@/components/common/dropzone";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { ImageViewer } from "@/components/common/image-viewer"; import { ImageViewer } from "@/components/common/image-viewer";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ReviewPanel } from "@/components/common/review-panel"; import { ReviewPanel } from "@/components/common/review-panel";
@@ -55,7 +55,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings"; import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import { SmartCropSettings } from "@/components/tools/smart-crop-settings"; import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
import { CheckCircle2, Download } from "lucide-react"; import { CheckCircle2, Download, ChevronLeft, ChevronRight } from "lucide-react";
const COLOR_TOOL_IDS = new Set([ const COLOR_TOOL_IDS = new Set([
"brightness-contrast", "brightness-contrast",
@@ -66,8 +66,9 @@ const COLOR_TOOL_IDS = new Set([
// Tools that don't need a file dropzone (they generate content or have custom UI) // Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]); const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop"]); const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate"]);
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]); const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]); const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
function ToolSettingsPanel({ function ToolSettingsPanel({
@@ -194,8 +195,20 @@ export function ToolPage() {
undoProcessing, undoProcessing,
batchZipBlob, batchZipBlob,
batchZipFilename, batchZipFilename,
selectedIndex,
setSelectedIndex,
navigateNext,
navigatePrev,
} = useFileStore(); } = useFileStore();
const isMobile = useMobile(); const isMobile = useMobile();
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleImageKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); }
else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); }
}, [navigateNext, navigatePrev]);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null); const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
@@ -367,63 +380,87 @@ export function ToolPage() {
)} )}
{/* Main area: Dropzone / Image Viewer / Before-After */} {/* Main area: Dropzone / Image Viewer / Before-After */}
<div className="flex-1 flex items-center justify-center p-4"> <div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
{isNoDropzone ? ( <div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
<div className="text-center text-muted-foreground"> {hasMultiple && hasPrev && (
<p className="text-sm">Configure settings and generate.</p> <button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
</div> <ChevronLeft className="h-4 w-4" />
) : files.length > 1 ? ( </button>
<MultiImageViewer /> )}
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? ( {isNoDropzone ? (
<CropCanvas <div className="text-center text-muted-foreground">
imageSrc={originalBlobUrl} <p className="text-sm">Configure settings and generate.</p>
crop={cropCrop} </div>
aspect={cropAspect} ) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
showGrid={cropShowGrid} <CropCanvas
imgDimensions={cropImgDimensions} imageSrc={originalBlobUrl}
onCropChange={setCropCrop} crop={cropCrop}
onImageLoad={setCropImgDimensions} aspect={cropAspect}
/> showGrid={cropShowGrid}
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( imgDimensions={cropImgDimensions}
<SideBySideComparison onCropChange={setCropCrop}
beforeSrc={originalBlobUrl} onImageLoad={setCropImgDimensions}
afterSrc={processedUrl} />
beforeSize={originalSize ?? undefined} ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
afterSize={processedSize ?? undefined} <SideBySideComparison
/> beforeSrc={originalBlobUrl}
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( afterSrc={processedUrl}
<ImageViewer beforeSize={originalSize ?? undefined}
src={processedUrl} afterSize={processedSize ?? undefined}
filename={processedFileName} />
fileSize={processedSize ?? 0} ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
/> <ImageViewer
) : hasProcessed && originalBlobUrl ? ( src={processedUrl}
<BeforeAfterSlider filename={processedFileName}
beforeSrc={originalBlobUrl} fileSize={processedSize ?? 0}
afterSrc={processedUrl} />
beforeSize={originalSize ?? undefined} ) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
afterSize={processedSize ?? undefined} <ImageViewer
/> src={processedUrl}
) : hasFile && originalBlobUrl ? ( filename={processedFileName}
<ImageViewer fileSize={processedSize ?? 0}
src={originalBlobUrl} />
filename={selectedFileName ?? files[0].name} ) : hasProcessed && originalBlobUrl ? (
fileSize={selectedFileSize ?? files[0].size} <BeforeAfterSlider
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform beforeSrc={originalBlobUrl}
? { afterSrc={processedUrl}
cssRotate: previewTransform.rotate, beforeSize={originalSize ?? undefined}
cssFlipH: previewTransform.flipH, afterSize={processedSize ?? undefined}
cssFlipV: previewTransform.flipV, />
} ) : hasFile && originalBlobUrl ? (
: {})} <ImageViewer
/> src={originalBlobUrl}
) : ( filename={selectedFileName ?? files[0].name}
<Dropzone fileSize={selectedFileSize ?? files[0].size}
onFiles={handleFiles} {...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
accept="image/*" ? {
multiple cssRotate: previewTransform.rotate,
currentFiles={files} cssFlipH: previewTransform.flipH,
/> cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
{hasMultiple && hasNext && (
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</div>
{hasMultiple && (
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
)} )}
</div> </div>
</div> </div>
@@ -510,63 +547,87 @@ export function ToolPage() {
</div> </div>
{/* Main area: Dropzone / Image Viewer / Before-After */} {/* Main area: Dropzone / Image Viewer / Before-After */}
<div className="flex-1 flex items-center justify-center p-6"> <div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
{isNoDropzone ? ( <div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
<div className="text-center text-muted-foreground"> {hasMultiple && hasPrev && (
<p className="text-sm">Configure settings and generate.</p> <button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
</div> <ChevronLeft className="h-4 w-4" />
) : files.length > 1 ? ( </button>
<MultiImageViewer /> )}
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? ( {isNoDropzone ? (
<CropCanvas <div className="text-center text-muted-foreground">
imageSrc={originalBlobUrl} <p className="text-sm">Configure settings and generate.</p>
crop={cropCrop} </div>
aspect={cropAspect} ) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
showGrid={cropShowGrid} <CropCanvas
imgDimensions={cropImgDimensions} imageSrc={originalBlobUrl}
onCropChange={setCropCrop} crop={cropCrop}
onImageLoad={setCropImgDimensions} aspect={cropAspect}
/> showGrid={cropShowGrid}
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( imgDimensions={cropImgDimensions}
<SideBySideComparison onCropChange={setCropCrop}
beforeSrc={originalBlobUrl} onImageLoad={setCropImgDimensions}
afterSrc={processedUrl} />
beforeSize={originalSize ?? undefined} ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
afterSize={processedSize ?? undefined} <SideBySideComparison
/> beforeSrc={originalBlobUrl}
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( afterSrc={processedUrl}
<ImageViewer beforeSize={originalSize ?? undefined}
src={processedUrl} afterSize={processedSize ?? undefined}
filename={processedFileName} />
fileSize={processedSize ?? 0} ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
/> <ImageViewer
) : hasProcessed && originalBlobUrl ? ( src={processedUrl}
<BeforeAfterSlider filename={processedFileName}
beforeSrc={originalBlobUrl} fileSize={processedSize ?? 0}
afterSrc={processedUrl} />
beforeSize={originalSize ?? undefined} ) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
afterSize={processedSize ?? undefined} <ImageViewer
/> src={processedUrl}
) : hasFile && originalBlobUrl ? ( filename={processedFileName}
<ImageViewer fileSize={processedSize ?? 0}
src={originalBlobUrl} />
filename={selectedFileName ?? files[0].name} ) : hasProcessed && originalBlobUrl ? (
fileSize={selectedFileSize ?? files[0].size} <BeforeAfterSlider
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform beforeSrc={originalBlobUrl}
? { afterSrc={processedUrl}
cssRotate: previewTransform.rotate, beforeSize={originalSize ?? undefined}
cssFlipH: previewTransform.flipH, afterSize={processedSize ?? undefined}
cssFlipV: previewTransform.flipV, />
} ) : hasFile && originalBlobUrl ? (
: {})} <ImageViewer
/> src={originalBlobUrl}
) : ( filename={selectedFileName ?? files[0].name}
<Dropzone fileSize={selectedFileSize ?? files[0].size}
onFiles={handleFiles} {...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
accept="image/*" ? {
multiple cssRotate: previewTransform.rotate,
currentFiles={files} cssFlipH: previewTransform.flipH,
/> cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
{hasMultiple && hasNext && (
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</div>
{hasMultiple && (
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
)} )}
</div> </div>
</div> </div>
+6 -6
View File
@@ -6,7 +6,7 @@
# ============================================ # ============================================
# Stage 1: Build the frontend (Vite + React) # Stage 1: Build the frontend (Vite + React)
# ============================================ # ============================================
FROM node:22-bookworm AS builder FROM --platform=linux/amd64 node:22-bookworm AS builder
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -35,7 +35,7 @@ RUN pnpm --filter @stirling-image/web build
# ============================================ # ============================================
# Stage 2: Production runtime # Stage 2: Production runtime
# ============================================ # ============================================
FROM node:22-bookworm AS production FROM --platform=linux/amd64 node:22-bookworm AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -67,10 +67,6 @@ RUN /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
(/opt/venv/bin/pip install --no-cache-dir lama-cleaner || echo "WARNING: lama-cleaner not installed - object eraser will be unavailable") && \ (/opt/venv/bin/pip install --no-cache-dir lama-cleaner || echo "WARNING: lama-cleaner not installed - object eraser will be unavailable") && \
rm /tmp/requirements.txt rm /tmp/requirements.txt
# Remove build tools no longer needed in production
RUN apt-get purge -y --auto-remove build-essential python3-dev && \
rm -rf /var/lib/apt/lists/*
# Pre-download ALL AI model weights into the image (no first-use download delays) # Pre-download ALL AI model weights into the image (no first-use download delays)
# This makes the Docker image fully self-contained — works offline # This makes the Docker image fully self-contained — works offline
COPY docker/download_models.py /tmp/download_models.py COPY docker/download_models.py /tmp/download_models.py
@@ -99,6 +95,10 @@ COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
# Install production dependencies (tsx is now in prod deps) # Install production dependencies (tsx is now in prod deps)
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
# Remove build tools no longer needed in production
RUN apt-get purge -y --auto-remove build-essential python3-dev && \
rm -rf /var/lib/apt/lists/*
# Copy source code for API (tsx runs TS directly - no build step needed) # Copy source code for API (tsx runs TS directly - no build step needed)
COPY apps/api/src ./apps/api/src COPY apps/api/src ./apps/api/src
COPY apps/api/drizzle ./apps/api/drizzle COPY apps/api/drizzle ./apps/api/drizzle
+2 -76
View File
@@ -74,12 +74,6 @@ importers:
'@fastify/static': '@fastify/static':
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.3.0 version: 8.3.0
'@fastify/swagger':
specifier: ^9.4.0
version: 9.7.0
'@fastify/swagger-ui':
specifier: ^5.2.0
version: 5.2.5
'@stirling-image/ai': '@stirling-image/ai':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ai version: link:../../packages/ai
@@ -1310,15 +1304,6 @@ packages:
'@fastify/static@8.3.0': '@fastify/static@8.3.0':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
'@fastify/static@9.0.0':
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
'@fastify/swagger-ui@5.2.5':
resolution: {integrity: sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==}
'@fastify/swagger@9.7.0':
resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==}
'@iconify-json/simple-icons@1.2.74': '@iconify-json/simple-icons@1.2.74':
resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==} resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==}
@@ -2829,10 +2814,6 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
content-disposition@1.0.1:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
conventional-changelog-angular@8.3.0: conventional-changelog-angular@8.3.0:
resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==} resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3411,10 +3392,6 @@ packages:
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
global@4.4.0: global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -3679,10 +3656,6 @@ packages:
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
json-schema-resolver@3.0.0:
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
engines: {node: '>=20'}
json-schema-traverse@1.0.0: json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -4122,9 +4095,6 @@ packages:
oniguruma-to-es@3.1.1: oniguruma-to-es@3.1.1:
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
p-each-series@3.0.0: p-each-series@3.0.0:
resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6110,33 +6080,6 @@ snapshots:
fastq: 1.20.1 fastq: 1.20.1
glob: 11.1.0 glob: 11.1.0
'@fastify/static@9.0.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 1.0.1
fastify-plugin: 5.1.0
fastq: 1.20.1
glob: 13.0.6
'@fastify/swagger-ui@5.2.5':
dependencies:
'@fastify/static': 9.0.0
fastify-plugin: 5.1.0
openapi-types: 12.1.3
rfdc: 1.4.1
yaml: 2.8.3
'@fastify/swagger@9.7.0':
dependencies:
fastify-plugin: 5.1.0
json-schema-resolver: 3.0.0
openapi-types: 12.1.3
rfdc: 1.4.1
yaml: 2.8.3
transitivePeerDependencies:
- supports-color
'@iconify-json/simple-icons@1.2.74': '@iconify-json/simple-icons@1.2.74':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -7845,8 +7788,6 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
content-disposition@1.0.1: {}
conventional-changelog-angular@8.3.0: conventional-changelog-angular@8.3.0:
dependencies: dependencies:
compare-func: 2.0.0 compare-func: 2.0.0
@@ -8453,12 +8394,6 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.2 path-scurry: 2.0.2
glob@13.0.6:
dependencies:
minimatch: 10.2.4
minipass: 7.1.3
path-scurry: 2.0.2
global@4.4.0: global@4.4.0:
dependencies: dependencies:
min-document: 2.19.2 min-document: 2.19.2
@@ -8733,14 +8668,6 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
json-schema-resolver@3.0.0:
dependencies:
debug: 4.4.3
fast-uri: 3.1.0
rfdc: 1.4.1
transitivePeerDependencies:
- supports-color
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
json-with-bigint@3.5.8: {} json-with-bigint@3.5.8: {}
@@ -9083,8 +9010,6 @@ snapshots:
regex: 6.1.0 regex: 6.1.0
regex-recursion: 6.0.2 regex-recursion: 6.0.2
openapi-types@12.1.3: {}
p-each-series@3.0.0: {} p-each-series@3.0.0: {}
p-event@6.0.1: p-event@6.0.1:
@@ -10301,7 +10226,8 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.8.3: {} yaml@2.8.3:
optional: true
yargs-parser@18.1.3: yargs-parser@18.1.3:
dependencies: dependencies: