mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(lint): resolve all biome warnings across API, web, and image-engine
API: - Replace string concatenation with template literals (batch, pipeline, tool-factory, content-aware-resize, passport-photo, remove-background) - Remove unused imports (teams, color-adjustments, image-enhancement) - Replace non-null assertions with guard clauses in bg-effects and stitch - Use optional chaining in docs route Image-engine: - Remove unused OutputFormat imports (compress, convert) - Use local variables instead of reassigning parameters (optimize-for-web, sharpen) Web: - Fix useExhaustiveDependencies: remove genuinely redundant deps, add biome-ignore comments for intentional patterns (src prop, cleanup fns) - Replace non-null assertions with null-safe alternatives - Add accessible titles to inline SVGs (color-settings, image-enhancement-settings) - Fix noLabelWithoutControl: associate labels via htmlFor/id or use <span> (image-to-base64-settings, edit-metadata-settings, qr-generate-settings) - Replace div[role="button"] with <button> (pdf-to-image-settings) - Use stable keys instead of array indices (collage-preview, collage-settings) - Fix noStaticElementInteractions in collage-preview (role="none") - Remove unused function parameters and imports
This commit is contained in:
@@ -44,8 +44,8 @@ export async function blurBackground(
|
||||
*/
|
||||
export async function addDropShadow(subjectBuffer: Buffer, opacity: number): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
||||
const { width, height } = meta;
|
||||
const normalizedOpacity = Math.max(0, Math.min(100, opacity)) / 100;
|
||||
|
||||
// Shadow parameters
|
||||
@@ -121,6 +121,7 @@ export async function createGradientBackground(
|
||||
*/
|
||||
export async function compositeOnColor(subjectBuffer: Buffer, hexColor: string): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
||||
const hex = hexColor.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
@@ -128,8 +129,8 @@ export async function compositeOnColor(subjectBuffer: Buffer, hexColor: string):
|
||||
|
||||
return sharp({
|
||||
create: {
|
||||
width: meta.width!,
|
||||
height: meta.height!,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
channels: 4,
|
||||
background: { r, g, b, alpha: 255 },
|
||||
},
|
||||
@@ -148,8 +149,8 @@ export async function compositeOnImage(
|
||||
backgroundBuffer: Buffer,
|
||||
): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
||||
const { width, height } = meta;
|
||||
|
||||
const resizedBg = await sharp(backgroundBuffer)
|
||||
.resize(width, height, { fit: "cover" })
|
||||
@@ -184,8 +185,8 @@ export async function applyEffects(
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
||||
const { width, height } = meta;
|
||||
const bgType = settings.backgroundType || "transparent";
|
||||
|
||||
// Step 1: Add shadow to the subject (before background compositing)
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
processBuffer = await decodeHeic(processBuffer);
|
||||
// Update extension to match decoded format (HEIC/HEIF → PNG)
|
||||
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) processFilename = processFilename.slice(0, -ext.length) + ".png";
|
||||
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
if (!skipPreprocess) {
|
||||
processBuffer = await autoOrient(processBuffer);
|
||||
|
||||
@@ -81,7 +81,7 @@ function generateLlmsFullTxt(spec: OpenAPISpec): string {
|
||||
for (const [method, op] of Object.entries(methods)) {
|
||||
const tag = op.tags?.[0] || "Other";
|
||||
if (!tagGroups.has(tag)) tagGroups.set(tag, []);
|
||||
tagGroups.get(tag)!.push({ method: method.toUpperCase(), path, op });
|
||||
tagGroups.get(tag)?.push({ method: method.toUpperCase(), path, op });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
// Update filename extension to match the decoded format
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
@@ -513,7 +513,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
if (validation.format === "heif") {
|
||||
currentBuffer = await decodeHeic(currentBuffer);
|
||||
const ext = currentFilename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) currentFilename = currentFilename.slice(0, -ext.length) + ".png";
|
||||
if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
|
||||
// Normalize EXIF orientation
|
||||
|
||||
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { requireAdmin, requireAuth } from "../plugins/auth.js";
|
||||
import { requireAdmin } from "../plugins/auth.js";
|
||||
|
||||
function validateTeamName(name: unknown): string | null {
|
||||
if (typeof name !== "string") return "Team name is required";
|
||||
|
||||
@@ -156,7 +156,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
brightness as adjustBrightness,
|
||||
contrast as adjustContrast,
|
||||
saturation as adjustSaturation,
|
||||
sharpen as adjustSharpen,
|
||||
colorChannels,
|
||||
grayscale,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC/HEIF file",
|
||||
|
||||
@@ -4,7 +4,6 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
@@ -160,7 +160,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
|
||||
@@ -83,7 +83,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
|
||||
@@ -205,8 +205,9 @@ export function registerStitch(app: FastifyInstance) {
|
||||
|
||||
if (settings.cornerRadius > 0) {
|
||||
const meta = await sharp(result).metadata();
|
||||
const w = meta.width!;
|
||||
const h = meta.height!;
|
||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
||||
const w = meta.width;
|
||||
const h = meta.height;
|
||||
const r = Math.min(settings.cornerRadius, Math.floor(Math.min(w, h) / 2));
|
||||
|
||||
const mask = Buffer.from(
|
||||
|
||||
@@ -90,6 +90,7 @@ export function ImageViewer({
|
||||
}, []);
|
||||
|
||||
// Reset state on src change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: src is a prop that triggers state reset
|
||||
useEffect(() => {
|
||||
setZoom(DEFAULT_ZOOM);
|
||||
setFitMode("fit");
|
||||
|
||||
@@ -46,7 +46,10 @@ export function MultiImageViewer() {
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
const hasProcessed = !!currentEntry.processedUrl;
|
||||
const isPreviewable = hasProcessed && canBrowserPreview(currentEntry.processedUrl!);
|
||||
const isPreviewable =
|
||||
hasProcessed && currentEntry.processedUrl
|
||||
? canBrowserPreview(currentEntry.processedUrl)
|
||||
: false;
|
||||
const displayUrl = currentEntry.processedPreviewUrl ?? currentEntry.processedUrl;
|
||||
|
||||
const processedFilename = currentEntry.processedUrl
|
||||
|
||||
@@ -149,7 +149,7 @@ function CollageCanvas({ template }: { template: CollageTemplate }) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-auto"
|
||||
role="presentation"
|
||||
role="none"
|
||||
onClick={() => store.setSelectedCell(null)}
|
||||
onKeyDown={(e) => e.key === "Escape" && store.setSelectedCell(null)}
|
||||
>
|
||||
@@ -178,7 +178,7 @@ function CollageCanvas({ template }: { template: CollageTemplate }) {
|
||||
|
||||
return (
|
||||
<CollageCell
|
||||
key={`${template.id}-${i}`}
|
||||
key={`${template.id}-${cell.gridColumn}-${cell.gridRow}`}
|
||||
cellIndex={i}
|
||||
image={img}
|
||||
transform={transform}
|
||||
@@ -290,6 +290,7 @@ function CollageCell({
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: cell requires drag/zoom interactions incompatible with button element
|
||||
<div
|
||||
ref={cellRef}
|
||||
role="button"
|
||||
|
||||
@@ -406,9 +406,9 @@ function TemplateDiagram({ template, size }: { template: CollageTemplate; size:
|
||||
role="img"
|
||||
aria-label={template.label}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
{rects.map((r) => (
|
||||
<rect
|
||||
key={`${template.id}-${i}`}
|
||||
key={`${template.id}-${r.x}-${r.y}-${r.w}-${r.h}`}
|
||||
x={padding + r.x}
|
||||
y={padding + r.y}
|
||||
width={r.w}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ColorControlsProps {
|
||||
}
|
||||
|
||||
export function ColorControls({
|
||||
toolId,
|
||||
toolId: _toolId,
|
||||
settings: initialSettings,
|
||||
onChange,
|
||||
onPreviewFilter,
|
||||
@@ -117,8 +117,6 @@ export function ColorControls({
|
||||
contrast,
|
||||
exposure,
|
||||
saturation,
|
||||
temperature,
|
||||
tint,
|
||||
hue,
|
||||
sharpness,
|
||||
hasChannelChanges,
|
||||
@@ -150,6 +148,7 @@ export function ColorControls({
|
||||
{/* Hidden SVG filters for live preview */}
|
||||
{hasTempTint && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<title>Temperature and tint color filter</title>
|
||||
<filter id="ashim-temp-tint-filter" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
@@ -160,6 +159,7 @@ export function ColorControls({
|
||||
)}
|
||||
{hasChannelChanges && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<title>Color channel filter</title>
|
||||
<filter id="ashim-channel-filter" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
@@ -170,6 +170,7 @@ export function ColorControls({
|
||||
)}
|
||||
{sharpness > 0 && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<title>Sharpen filter</title>
|
||||
<filter id="ashim-sharpen-filter" colorInterpolationFilters="sRGB">
|
||||
<feConvolveMatrix
|
||||
order="3"
|
||||
|
||||
@@ -536,8 +536,14 @@ export function EditMetadataSettings() {
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-foreground">Direction</label>
|
||||
<label
|
||||
htmlFor="em-date-shift-direction"
|
||||
className="text-xs font-medium text-foreground"
|
||||
>
|
||||
Direction
|
||||
</label>
|
||||
<select
|
||||
id="em-date-shift-direction"
|
||||
value={form.dateShiftDirection}
|
||||
onChange={(e) => setField("dateShiftDirection", e.target.value as "+" | "-")}
|
||||
className="px-2.5 py-1.5 rounded-md border border-input bg-background text-sm"
|
||||
|
||||
@@ -45,6 +45,7 @@ export function FaviconSettings() {
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup uses only stable refs and state setters
|
||||
const handleProcess = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export function FindDuplicatesSettings() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset scan results when files change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: files is a store value that triggers reset when changed
|
||||
useEffect(() => {
|
||||
resetDuplicates();
|
||||
setError(null);
|
||||
|
||||
@@ -147,7 +147,6 @@ export function GifToolsControls({ settings: initialSettings, onChange }: GifToo
|
||||
width,
|
||||
height,
|
||||
percentage,
|
||||
lockAspect,
|
||||
colors,
|
||||
dither,
|
||||
effort,
|
||||
|
||||
@@ -127,7 +127,7 @@ interface ImageEnhancementControlsProps {
|
||||
}
|
||||
|
||||
export function ImageEnhancementControls({
|
||||
settings: initialSettings,
|
||||
settings: _initialSettings,
|
||||
onChange,
|
||||
onPreviewFilter,
|
||||
}: ImageEnhancementControlsProps) {
|
||||
@@ -245,6 +245,7 @@ export function ImageEnhancementControls({
|
||||
{/* Hidden SVG filters for preview */}
|
||||
{toggles.whiteBalance && Math.abs(tempAdj) > 0.02 && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<title>White balance temperature filter</title>
|
||||
<filter id="ashim-enhance-temp-filter" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
@@ -255,6 +256,7 @@ export function ImageEnhancementControls({
|
||||
)}
|
||||
{toggles.sharpness && sharpAdj > 0.02 && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<title>Sharpen filter</title>
|
||||
<filter id="ashim-enhance-sharpen-filter" colorInterpolationFilters="sRGB">
|
||||
<feConvolveMatrix
|
||||
order="3"
|
||||
|
||||
@@ -90,7 +90,7 @@ function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
|
||||
function FileResult({ result }: { result: Base64Result }) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("datauri");
|
||||
const tab = TABS.find((t) => t.id === activeTab)!;
|
||||
const tab = TABS.find((t) => t.id === activeTab) ?? TABS[0];
|
||||
const output = tab.generate(result);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
|
||||
@@ -74,7 +74,7 @@ export function ImageToBase64Settings() {
|
||||
<div className="space-y-4">
|
||||
{/* Output Format */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Output Image Format</label>
|
||||
<span className="text-xs font-medium text-muted-foreground">Output Image Format</span>
|
||||
<p className="text-[10px] text-muted-foreground/70 mb-1.5">
|
||||
Convert before encoding to control MIME type and size
|
||||
</p>
|
||||
@@ -100,10 +100,13 @@ export function ImageToBase64Settings() {
|
||||
{showQuality && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">Quality</label>
|
||||
<label htmlFor="b64-quality" className="text-xs font-medium text-muted-foreground">
|
||||
Quality
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="b64-quality"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
@@ -119,8 +122,11 @@ export function ImageToBase64Settings() {
|
||||
|
||||
{/* Max Width */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Max Width (px)</label>
|
||||
<label htmlFor="b64-max-width" className="text-xs font-medium text-muted-foreground">
|
||||
Max Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="b64-max-width"
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxWidth}
|
||||
@@ -132,8 +138,11 @@ export function ImageToBase64Settings() {
|
||||
|
||||
{/* Max Height */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Max Height (px)</label>
|
||||
<label htmlFor="b64-max-height" className="text-xs font-medium text-muted-foreground">
|
||||
Max Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="b64-max-height"
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxHeight}
|
||||
|
||||
@@ -140,6 +140,7 @@ export function ImageToPdfSettings() {
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup uses only stable refs and state setters
|
||||
const handleProcess = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
|
||||
@@ -522,6 +522,11 @@ export function PassportPhotoSettings() {
|
||||
documentType,
|
||||
bgColor,
|
||||
maxFileSizeKb,
|
||||
dpi,
|
||||
zoom,
|
||||
isCustom,
|
||||
customWidthMm,
|
||||
customHeightMm,
|
||||
adjustX,
|
||||
adjustY,
|
||||
setGenerating,
|
||||
|
||||
@@ -70,19 +70,12 @@ export function PdfToImageSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* PDF upload area */}
|
||||
{!store.file ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors w-full"
|
||||
>
|
||||
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Drop a PDF here or click to select</p>
|
||||
@@ -93,7 +86,7 @@ export function PdfToImageSettings() {
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -95,15 +95,18 @@ function SortableStep({
|
||||
className="flex items-center gap-2 p-3 w-full text-left"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<span
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</span>
|
||||
{
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers
|
||||
<span
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</span>
|
||||
}
|
||||
|
||||
{/* Step number badge */}
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
||||
@@ -122,27 +125,18 @@ function SortableStep({
|
||||
<span className="flex-1" />
|
||||
|
||||
{/* Remove button */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}
|
||||
}}
|
||||
title="Remove"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{/* Inline settings panel */}
|
||||
<div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}>
|
||||
<PipelineStepSettings
|
||||
toolId={step.toolId}
|
||||
@@ -150,6 +144,7 @@ function SortableStep({
|
||||
onChange={onUpdateSettings}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function QrGeneratePreview() {
|
||||
]);
|
||||
|
||||
// Create QR instance on mount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: options is intentionally excluded to only create QR instance once on mount
|
||||
useEffect(() => {
|
||||
const qr = new QRCodeStyling(options as never);
|
||||
qrRef.current = qr;
|
||||
@@ -99,7 +100,7 @@ export function QrGeneratePreview() {
|
||||
clearChildren(containerRef.current);
|
||||
qr.append(containerRef.current);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update QR on state changes (debounced)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -80,8 +80,11 @@ function UrlForm() {
|
||||
const { textData, setTextData } = useQrStore();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">URL</label>
|
||||
<label htmlFor="qr-url" className="text-xs text-muted-foreground">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="qr-url"
|
||||
type="url"
|
||||
value={textData}
|
||||
onChange={(e) => setTextData(e.target.value)}
|
||||
@@ -97,8 +100,11 @@ function TextForm() {
|
||||
const { textData, setTextData } = useQrStore();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text</label>
|
||||
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
|
||||
Text
|
||||
</label>
|
||||
<textarea
|
||||
id="qr-text"
|
||||
value={textData}
|
||||
onChange={(e) => setTextData(e.target.value)}
|
||||
placeholder="Enter any text..."
|
||||
@@ -115,8 +121,11 @@ function WifiForm() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Network Name (SSID)</label>
|
||||
<label htmlFor="qr-wifi-ssid" className="text-xs text-muted-foreground">
|
||||
Network Name (SSID)
|
||||
</label>
|
||||
<input
|
||||
id="qr-wifi-ssid"
|
||||
type="text"
|
||||
value={wifiData.ssid}
|
||||
onChange={(e) => setWifiData({ ssid: e.target.value })}
|
||||
@@ -125,8 +134,11 @@ function WifiForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Password</label>
|
||||
<label htmlFor="qr-wifi-password" className="text-xs text-muted-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="qr-wifi-password"
|
||||
type="text"
|
||||
value={wifiData.password}
|
||||
onChange={(e) => setWifiData({ password: e.target.value })}
|
||||
@@ -135,8 +147,11 @@ function WifiForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Encryption</label>
|
||||
<label htmlFor="qr-wifi-encryption" className="text-xs text-muted-foreground">
|
||||
Encryption
|
||||
</label>
|
||||
<select
|
||||
id="qr-wifi-encryption"
|
||||
value={wifiData.encryption}
|
||||
onChange={(e) => setWifiData({ encryption: e.target.value as "WPA" | "WEP" | "nopass" })}
|
||||
className={INPUT_CLASS}
|
||||
@@ -163,8 +178,11 @@ function VCardForm() {
|
||||
const { vcardData, setVcardData } = useQrStore();
|
||||
const field = (label: string, key: keyof typeof vcardData, placeholder: string) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<label htmlFor={`qr-vcard-${key}`} className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={`qr-vcard-${key}`}
|
||||
type="text"
|
||||
value={vcardData[key]}
|
||||
onChange={(e) => setVcardData({ [key]: e.target.value })}
|
||||
@@ -193,8 +211,11 @@ function EmailForm() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">To</label>
|
||||
<label htmlFor="qr-email-to" className="text-xs text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
id="qr-email-to"
|
||||
type="email"
|
||||
value={emailData.to}
|
||||
onChange={(e) => setEmailData({ to: e.target.value })}
|
||||
@@ -203,8 +224,11 @@ function EmailForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Subject</label>
|
||||
<label htmlFor="qr-email-subject" className="text-xs text-muted-foreground">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="qr-email-subject"
|
||||
type="text"
|
||||
value={emailData.subject}
|
||||
onChange={(e) => setEmailData({ subject: e.target.value })}
|
||||
@@ -213,8 +237,11 @@ function EmailForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Body</label>
|
||||
<label htmlFor="qr-email-body" className="text-xs text-muted-foreground">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
id="qr-email-body"
|
||||
value={emailData.body}
|
||||
onChange={(e) => setEmailData({ body: e.target.value })}
|
||||
placeholder="Email body..."
|
||||
@@ -230,8 +257,11 @@ function PhoneForm() {
|
||||
const { phoneData, setPhoneData } = useQrStore();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Phone Number</label>
|
||||
<label htmlFor="qr-phone" className="text-xs text-muted-foreground">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
id="qr-phone"
|
||||
type="tel"
|
||||
value={phoneData}
|
||||
onChange={(e) => setPhoneData(e.target.value)}
|
||||
@@ -247,8 +277,11 @@ function SmsForm() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Phone Number</label>
|
||||
<label htmlFor="qr-sms-phone" className="text-xs text-muted-foreground">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
id="qr-sms-phone"
|
||||
type="tel"
|
||||
value={smsData.phone}
|
||||
onChange={(e) => setSmsData({ phone: e.target.value })}
|
||||
@@ -257,8 +290,11 @@ function SmsForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Message</label>
|
||||
<label htmlFor="qr-sms-message" className="text-xs text-muted-foreground">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="qr-sms-message"
|
||||
value={smsData.message}
|
||||
onChange={(e) => setSmsData({ message: e.target.value })}
|
||||
placeholder="Your message..."
|
||||
@@ -405,7 +441,7 @@ export function QrGenerateSettings() {
|
||||
<CollapsibleSection title="Style" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Dot Pattern</label>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Dot Pattern</span>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{DOT_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
@@ -419,7 +455,7 @@ export function QrGenerateSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Square</label>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Corner Square</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{CORNER_SQUARE_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
@@ -433,7 +469,7 @@ export function QrGenerateSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Dot</label>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Corner Dot</span>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{CORNER_DOT_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
@@ -453,7 +489,7 @@ export function QrGenerateSettings() {
|
||||
<CollapsibleSection title="Colors">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Dot Color</label>
|
||||
<span className="text-xs text-muted-foreground">Dot Color</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
type="color"
|
||||
@@ -487,8 +523,11 @@ export function QrGenerateSettings() {
|
||||
<div className="space-y-2 pl-2 border-l-2 border-primary/20 ml-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">From</label>
|
||||
<label htmlFor="qr-gradient-from" className="text-[10px] text-muted-foreground">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
id="qr-gradient-from"
|
||||
type="color"
|
||||
value={store.dotGradientColor1}
|
||||
onChange={(e) => store.setDotGradientColor1(e.target.value)}
|
||||
@@ -496,8 +535,11 @@ export function QrGenerateSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">To</label>
|
||||
<label htmlFor="qr-gradient-to" className="text-[10px] text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
id="qr-gradient-to"
|
||||
type="color"
|
||||
value={store.dotGradientColor2}
|
||||
onChange={(e) => store.setDotGradientColor2(e.target.value)}
|
||||
@@ -522,12 +564,18 @@ export function QrGenerateSettings() {
|
||||
{store.dotGradientType === "linear" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] text-muted-foreground">Rotation</label>
|
||||
<label
|
||||
htmlFor="qr-gradient-rotation"
|
||||
className="text-[10px] text-muted-foreground"
|
||||
>
|
||||
Rotation
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-foreground">
|
||||
{store.dotGradientRotation}°
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="qr-gradient-rotation"
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
@@ -543,7 +591,7 @@ export function QrGenerateSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-muted-foreground">Background</label>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<label className="flex items-center gap-1 text-[10px] text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -586,8 +634,14 @@ export function QrGenerateSettings() {
|
||||
{store.useCustomCornerColors && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">Corner Square</label>
|
||||
<label
|
||||
htmlFor="qr-corner-square-color"
|
||||
className="text-[10px] text-muted-foreground"
|
||||
>
|
||||
Corner Square
|
||||
</label>
|
||||
<input
|
||||
id="qr-corner-square-color"
|
||||
type="color"
|
||||
value={store.cornerSquareColor}
|
||||
onChange={(e) => store.setCornerSquareColor(e.target.value)}
|
||||
@@ -595,8 +649,11 @@ export function QrGenerateSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">Corner Dot</label>
|
||||
<label htmlFor="qr-corner-dot-color" className="text-[10px] text-muted-foreground">
|
||||
Corner Dot
|
||||
</label>
|
||||
<input
|
||||
id="qr-corner-dot-color"
|
||||
type="color"
|
||||
value={store.cornerDotColor}
|
||||
onChange={(e) => store.setCornerDotColor(e.target.value)}
|
||||
@@ -651,12 +708,15 @@ export function QrGenerateSettings() {
|
||||
<>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Logo Size</label>
|
||||
<label htmlFor="qr-logo-size" className="text-xs text-muted-foreground">
|
||||
Logo Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">
|
||||
{Math.round(store.logoSize * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="qr-logo-size"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.5}
|
||||
@@ -668,10 +728,13 @@ export function QrGenerateSettings() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Logo Margin</label>
|
||||
<label htmlFor="qr-logo-margin" className="text-xs text-muted-foreground">
|
||||
Logo Margin
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{store.logoMargin}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="qr-logo-margin"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
@@ -699,7 +762,7 @@ export function QrGenerateSettings() {
|
||||
<CollapsibleSection title="Download" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Format</label>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Format</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{DOWNLOAD_FORMATS.map(({ value, label, desc }) => (
|
||||
<button
|
||||
@@ -729,10 +792,13 @@ export function QrGenerateSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Size</label>
|
||||
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
|
||||
Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{store.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="qr-size"
|
||||
type="range"
|
||||
min={200}
|
||||
max={2000}
|
||||
@@ -744,8 +810,11 @@ export function QrGenerateSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Error Correction</label>
|
||||
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
|
||||
Error Correction
|
||||
</label>
|
||||
<select
|
||||
id="qr-error-correction"
|
||||
value={store.errorCorrection}
|
||||
onChange={(e) => store.setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
|
||||
className={INPUT_CLASS}
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface RemoveBgControlsProps {
|
||||
onChange: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) {
|
||||
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
|
||||
const [subject, setSubject] = useState<SubjectType>("people");
|
||||
const [quality, setQuality] = useState<Quality>("balanced");
|
||||
const [isPassport, setIsPassport] = useState(true);
|
||||
@@ -543,7 +543,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
||||
const [bgFilename, setBgFilename] = useState<string | null>(null);
|
||||
const [bgOriginalUrl, setBgOriginalUrl] = useState<string | null>(null);
|
||||
const [effectsDownloadUrl, setEffectsDownloadUrl] = useState<string | null>(null);
|
||||
const [_effectsDownloadUrl, setEffectsDownloadUrl] = useState<string | null>(null);
|
||||
const [applyingEffects, setApplyingEffects] = useState(false);
|
||||
const [effectsError, setEffectsError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export function SplitCanvas() {
|
||||
}, [setImageDimensions, updateDisplaySize]);
|
||||
|
||||
// Reset error when blob URL changes (e.g., HEIC decoded preview ready)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: src is a prop that triggers state reset
|
||||
useEffect(() => {
|
||||
setLoadError(false);
|
||||
setDisplaySize(null);
|
||||
@@ -67,6 +68,7 @@ export function SplitCanvas() {
|
||||
}, [updateDisplaySize]);
|
||||
|
||||
// Re-measure when grid changes (in case image dimensions affect tile-size calculation)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: grid.columns and grid.rows trigger re-measurement of display
|
||||
useEffect(() => {
|
||||
updateDisplaySize();
|
||||
}, [grid.columns, grid.rows, updateDisplaySize]);
|
||||
@@ -188,10 +190,11 @@ export function SplitCanvas() {
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<title>Split grid overlay</title>
|
||||
{/* Vertical lines */}
|
||||
{colPositions.map((x, i) => (
|
||||
{colPositions.map((x) => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
key={`v-${x}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
@@ -202,9 +205,9 @@ export function SplitCanvas() {
|
||||
/>
|
||||
))}
|
||||
{/* Horizontal lines */}
|
||||
{rowPositions.map((y, i) => (
|
||||
{rowPositions.map((y) => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
key={`h-${y}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function SplitSettings() {
|
||||
? `Tiles will be very small (${tileDims.width}x${tileDims.height}px)`
|
||||
: null;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: files is a store value that triggers reset when changed
|
||||
useEffect(() => {
|
||||
setTiles([]);
|
||||
setZipBlobUrl(null);
|
||||
|
||||
@@ -188,7 +188,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
|
||||
}
|
||||
|
||||
export function UpscaleSettings() {
|
||||
const { files, entries } = useFileStore();
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user