mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add watermark, text overlay, and image composition tools
Add 4 watermark/overlay tools with API routes and frontend settings: - watermark-text: SVG text overlay with tiling, position, opacity, rotation - watermark-image: logo/image watermark with position, opacity, scale - text-overlay: styled text on images with shadow and background box - compose: layer images with position, opacity, and blend modes
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
x: z.number().min(0).default(0),
|
||||||
|
y: z.number().min(0).default(0),
|
||||||
|
opacity: z.number().min(0).max(100).default(100),
|
||||||
|
blendMode: z
|
||||||
|
.enum([
|
||||||
|
"over", "multiply", "screen", "overlay",
|
||||||
|
"darken", "lighten", "hard-light", "soft-light",
|
||||||
|
"difference", "exclusion",
|
||||||
|
])
|
||||||
|
.default("over"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerCompose(app: FastifyInstance) {
|
||||||
|
app.post(
|
||||||
|
"/api/v1/tools/compose",
|
||||||
|
async (request, reply) => {
|
||||||
|
let baseBuffer: Buffer | null = null;
|
||||||
|
let overlayBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = request.parts();
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.type === "file") {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of part.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
if (part.fieldname === "overlay") {
|
||||||
|
overlayBuffer = buf;
|
||||||
|
} else {
|
||||||
|
baseBuffer = buf;
|
||||||
|
filename = part.filename ?? "image";
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "settings") {
|
||||||
|
settingsRaw = part.value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseBuffer || baseBuffer.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No base image provided" });
|
||||||
|
}
|
||||||
|
if (!overlayBuffer || overlayBuffer.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No overlay image provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||||
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply opacity to overlay if needed
|
||||||
|
let processedOverlay = overlayBuffer;
|
||||||
|
if (settings.opacity < 100) {
|
||||||
|
const overlayImg = sharp(overlayBuffer).ensureAlpha();
|
||||||
|
const overlayBuf = await overlayImg.toBuffer();
|
||||||
|
const overlayMeta = await sharp(overlayBuf).metadata();
|
||||||
|
const oW = overlayMeta.width ?? 100;
|
||||||
|
const oH = overlayMeta.height ?? 100;
|
||||||
|
|
||||||
|
const opacityMask = await sharp({
|
||||||
|
create: {
|
||||||
|
width: oW,
|
||||||
|
height: oH,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
processedOverlay = await sharp(overlayBuf)
|
||||||
|
.composite([{ input: opacityMask, blend: "dest-in" }])
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sharp(baseBuffer)
|
||||||
|
.composite([{
|
||||||
|
input: processedOverlay,
|
||||||
|
top: settings.y,
|
||||||
|
left: settings.x,
|
||||||
|
blend: settings.blendMode as import("sharp").Blend,
|
||||||
|
}])
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
const outputPath = join(workspacePath, "output", filename);
|
||||||
|
await writeFile(outputPath, result);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
jobId,
|
||||||
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
||||||
|
originalSize: baseBuffer.length,
|
||||||
|
processedSize: result.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Processing failed",
|
||||||
|
details: err instanceof Error ? err.message : "Image processing failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
text: z.string().min(1).max(500),
|
||||||
|
fontSize: z.number().min(8).max(200).default(48),
|
||||||
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
|
||||||
|
position: z.enum(["top", "center", "bottom"]).default("bottom"),
|
||||||
|
backgroundBox: z.boolean().default(false),
|
||||||
|
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
|
||||||
|
shadow: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTextOverlay(app: FastifyInstance) {
|
||||||
|
createToolRoute(app, {
|
||||||
|
toolId: "text-overlay",
|
||||||
|
settingsSchema,
|
||||||
|
process: async (inputBuffer, settings, filename) => {
|
||||||
|
const image = sharp(inputBuffer);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const width = metadata.width ?? 800;
|
||||||
|
const height = metadata.height ?? 600;
|
||||||
|
const escapedText = escapeXml(settings.text);
|
||||||
|
|
||||||
|
let y: number;
|
||||||
|
const pad = settings.fontSize;
|
||||||
|
|
||||||
|
switch (settings.position) {
|
||||||
|
case "top":
|
||||||
|
y = pad + settings.fontSize;
|
||||||
|
break;
|
||||||
|
case "center":
|
||||||
|
y = height / 2;
|
||||||
|
break;
|
||||||
|
case "bottom":
|
||||||
|
default:
|
||||||
|
y = height - pad;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = width / 2;
|
||||||
|
|
||||||
|
// Build SVG text element with optional effects
|
||||||
|
let filter = "";
|
||||||
|
let filterRef = "";
|
||||||
|
if (settings.shadow) {
|
||||||
|
filter = `<defs><filter id="shadow"><feDropShadow dx="2" dy="2" stdDeviation="3" flood-color="rgba(0,0,0,0.7)"/></filter></defs>`;
|
||||||
|
filterRef = ' filter="url(#shadow)"';
|
||||||
|
}
|
||||||
|
|
||||||
|
let bgRect = "";
|
||||||
|
if (settings.backgroundBox) {
|
||||||
|
const boxH = settings.fontSize * 1.8;
|
||||||
|
const boxY = y - settings.fontSize * 0.9;
|
||||||
|
bgRect = `<rect x="0" y="${boxY}" width="${width}" height="${boxH}" fill="${settings.backgroundColor}" opacity="0.7"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgOverlay = `<svg width="${width}" height="${height}">
|
||||||
|
${filter}
|
||||||
|
${bgRect}
|
||||||
|
<text x="${x}" y="${y}" font-size="${settings.fontSize}" fill="${settings.color}" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle"${filterRef}>${escapedText}</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const svgBuffer = Buffer.from(svgOverlay);
|
||||||
|
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
|
||||||
|
const buffer = await result.toBuffer();
|
||||||
|
|
||||||
|
return { buffer, filename, contentType: "image/png" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
position: z
|
||||||
|
.enum(["center", "top-left", "top-right", "bottom-left", "bottom-right"])
|
||||||
|
.default("bottom-right"),
|
||||||
|
opacity: z.number().min(0).max(100).default(50),
|
||||||
|
scale: z.number().min(1).max(100).default(25),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerWatermarkImage(app: FastifyInstance) {
|
||||||
|
// Custom route since we need two file uploads
|
||||||
|
app.post(
|
||||||
|
"/api/v1/tools/watermark-image",
|
||||||
|
async (request, reply) => {
|
||||||
|
let mainBuffer: Buffer | null = null;
|
||||||
|
let watermarkBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = request.parts();
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.type === "file") {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of part.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
if (part.fieldname === "watermark") {
|
||||||
|
watermarkBuffer = buf;
|
||||||
|
} else {
|
||||||
|
mainBuffer = buf;
|
||||||
|
filename = part.filename ?? "image";
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "settings") {
|
||||||
|
settingsRaw = part.value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainBuffer || mainBuffer.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No main image file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse settings
|
||||||
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||||
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no watermark uploaded, just return the image
|
||||||
|
if (!watermarkBuffer || watermarkBuffer.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No watermark image provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mainImage = sharp(mainBuffer);
|
||||||
|
const mainMeta = await mainImage.metadata();
|
||||||
|
const mainW = mainMeta.width ?? 800;
|
||||||
|
const mainH = mainMeta.height ?? 600;
|
||||||
|
|
||||||
|
// Scale watermark
|
||||||
|
const wmWidth = Math.round((mainW * settings.scale) / 100);
|
||||||
|
let wmImage = sharp(watermarkBuffer).resize({ width: wmWidth });
|
||||||
|
|
||||||
|
// Apply opacity via ensureAlpha + modulate
|
||||||
|
if (settings.opacity < 100) {
|
||||||
|
const wmBuf = await wmImage.ensureAlpha().toBuffer();
|
||||||
|
const wmMeta = await sharp(wmBuf).metadata();
|
||||||
|
const wmW = wmMeta.width ?? wmWidth;
|
||||||
|
const wmH = wmMeta.height ?? wmWidth;
|
||||||
|
// Create an opacity mask
|
||||||
|
const opacityOverlay = await sharp({
|
||||||
|
create: {
|
||||||
|
width: wmW,
|
||||||
|
height: wmH,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
wmImage = sharp(wmBuf).composite([
|
||||||
|
{ input: opacityOverlay, blend: "dest-in" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wmBuffer = await wmImage.toBuffer();
|
||||||
|
const wmMeta = await sharp(wmBuffer).metadata();
|
||||||
|
const wmW = wmMeta.width ?? wmWidth;
|
||||||
|
const wmH = wmMeta.height ?? 0;
|
||||||
|
|
||||||
|
// Calculate position
|
||||||
|
const pad = 20;
|
||||||
|
let top = 0;
|
||||||
|
let left = 0;
|
||||||
|
|
||||||
|
switch (settings.position) {
|
||||||
|
case "top-left":
|
||||||
|
top = pad;
|
||||||
|
left = pad;
|
||||||
|
break;
|
||||||
|
case "top-right":
|
||||||
|
top = pad;
|
||||||
|
left = Math.max(0, mainW - wmW - pad);
|
||||||
|
break;
|
||||||
|
case "bottom-left":
|
||||||
|
top = Math.max(0, mainH - wmH - pad);
|
||||||
|
left = pad;
|
||||||
|
break;
|
||||||
|
case "bottom-right":
|
||||||
|
top = Math.max(0, mainH - wmH - pad);
|
||||||
|
left = Math.max(0, mainW - wmW - pad);
|
||||||
|
break;
|
||||||
|
case "center":
|
||||||
|
default:
|
||||||
|
top = Math.max(0, Math.round((mainH - wmH) / 2));
|
||||||
|
left = Math.max(0, Math.round((mainW - wmW) / 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sharp(mainBuffer)
|
||||||
|
.composite([{ input: wmBuffer, top, left }])
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Use tool-factory's workspace pattern
|
||||||
|
const { randomUUID } = await import("node:crypto");
|
||||||
|
const { writeFile } = await import("node:fs/promises");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const { createWorkspace } = await import("../../lib/workspace.js");
|
||||||
|
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
const outputPath = join(workspacePath, "output", filename);
|
||||||
|
await writeFile(outputPath, result);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
jobId,
|
||||||
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
||||||
|
originalSize: mainBuffer.length,
|
||||||
|
processedSize: result.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Processing failed",
|
||||||
|
details: err instanceof Error ? err.message : "Image processing failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
text: z.string().min(1).max(500),
|
||||||
|
fontSize: z.number().min(8).max(200).default(48),
|
||||||
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
|
||||||
|
opacity: z.number().min(0).max(100).default(50),
|
||||||
|
position: z
|
||||||
|
.enum(["center", "top-left", "top-right", "bottom-left", "bottom-right", "tiled"])
|
||||||
|
.default("center"),
|
||||||
|
rotation: z.number().min(-360).max(360).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, opacity: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r},${g},${b},${opacity / 100})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWatermarkText(app: FastifyInstance) {
|
||||||
|
createToolRoute(app, {
|
||||||
|
toolId: "watermark-text",
|
||||||
|
settingsSchema,
|
||||||
|
process: async (inputBuffer, settings, filename) => {
|
||||||
|
const image = sharp(inputBuffer);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const width = metadata.width ?? 800;
|
||||||
|
const height = metadata.height ?? 600;
|
||||||
|
const rgba = hexToRgba(settings.color, settings.opacity);
|
||||||
|
const escapedText = escapeXml(settings.text);
|
||||||
|
|
||||||
|
let svgOverlay: string;
|
||||||
|
|
||||||
|
if (settings.position === "tiled") {
|
||||||
|
// Create tiled watermark
|
||||||
|
const spacingX = settings.fontSize * 6;
|
||||||
|
const spacingY = settings.fontSize * 4;
|
||||||
|
let textElements = "";
|
||||||
|
for (let y = 0; y < height + spacingY; y += spacingY) {
|
||||||
|
for (let x = 0; x < width + spacingX; x += spacingX) {
|
||||||
|
textElements += `<text x="${x}" y="${y}" font-size="${settings.fontSize}" fill="${rgba}" font-family="sans-serif" transform="rotate(${settings.rotation},${x},${y})">${escapedText}</text>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svgOverlay = `<svg width="${width}" height="${height}">${textElements}</svg>`;
|
||||||
|
} else {
|
||||||
|
// Single watermark at specified position
|
||||||
|
let x: number, y: number;
|
||||||
|
let anchor = "middle";
|
||||||
|
const pad = settings.fontSize;
|
||||||
|
|
||||||
|
switch (settings.position) {
|
||||||
|
case "top-left":
|
||||||
|
x = pad;
|
||||||
|
y = pad + settings.fontSize;
|
||||||
|
anchor = "start";
|
||||||
|
break;
|
||||||
|
case "top-right":
|
||||||
|
x = width - pad;
|
||||||
|
y = pad + settings.fontSize;
|
||||||
|
anchor = "end";
|
||||||
|
break;
|
||||||
|
case "bottom-left":
|
||||||
|
x = pad;
|
||||||
|
y = height - pad;
|
||||||
|
anchor = "start";
|
||||||
|
break;
|
||||||
|
case "bottom-right":
|
||||||
|
x = width - pad;
|
||||||
|
y = height - pad;
|
||||||
|
anchor = "end";
|
||||||
|
break;
|
||||||
|
case "center":
|
||||||
|
default:
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
anchor = "middle";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
svgOverlay = `<svg width="${width}" height="${height}">
|
||||||
|
<text x="${x}" y="${y}" font-size="${settings.fontSize}" fill="${rgba}" font-family="sans-serif" text-anchor="${anchor}" transform="rotate(${settings.rotation},${x},${y})">${escapedText}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = Buffer.from(svgOverlay);
|
||||||
|
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
|
||||||
|
const buffer = await result.toBuffer();
|
||||||
|
|
||||||
|
return { buffer, filename, contentType: "image/png" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
return localStorage.getItem("stirling-token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposeSettings() {
|
||||||
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||||
|
const [overlayFile, setOverlayFile] = useState<File | null>(null);
|
||||||
|
const [x, setX] = useState(0);
|
||||||
|
const [y, setY] = useState(0);
|
||||||
|
const [opacity, setOpacity] = useState(100);
|
||||||
|
const [blendMode, setBlendMode] = useState("over");
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||||
|
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||||
|
const overlayInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleProcess = async () => {
|
||||||
|
if (files.length === 0 || !overlayFile) return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", files[0]);
|
||||||
|
formData.append("overlay", overlayFile);
|
||||||
|
formData.append("settings", JSON.stringify({ x, y, opacity, blendMode }));
|
||||||
|
|
||||||
|
const res = await fetch("/api/v1/tools/compose", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Processing failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
setJobId(result.jobId);
|
||||||
|
setProcessedUrl(result.downloadUrl);
|
||||||
|
setDownloadUrl(result.downloadUrl);
|
||||||
|
setOriginalSize(result.originalSize);
|
||||||
|
setProcessedSize(result.processedSize);
|
||||||
|
setSizes(result.originalSize, result.processedSize);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Processing failed");
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFile = files.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Overlay Image</label>
|
||||||
|
<input
|
||||||
|
ref={overlayInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setOverlayFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => overlayInputRef.current?.click()}
|
||||||
|
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{overlayFile ? overlayFile.name : "Choose overlay image"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground">X Position</label>
|
||||||
|
<input type="number" value={x} onChange={(e) => setX(Number(e.target.value))} min={0}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Y Position</label>
|
||||||
|
<input type="number" value={y} onChange={(e) => setY(Number(e.target.value))} min={0}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Blend Mode</label>
|
||||||
|
<select
|
||||||
|
value={blendMode}
|
||||||
|
onChange={(e) => setBlendMode(e.target.value)}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="over">Normal</option>
|
||||||
|
<option value="multiply">Multiply</option>
|
||||||
|
<option value="screen">Screen</option>
|
||||||
|
<option value="overlay">Overlay</option>
|
||||||
|
<option value="darken">Darken</option>
|
||||||
|
<option value="lighten">Lighten</option>
|
||||||
|
<option value="hard-light">Hard Light</option>
|
||||||
|
<option value="soft-light">Soft Light</option>
|
||||||
|
<option value="difference">Difference</option>
|
||||||
|
<option value="exclusion">Exclusion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{originalSize != null && processedSize != null && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||||
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={!hasFile || !overlayFile || processing}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{processing ? "Processing..." : "Compose"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function TextOverlaySettings() {
|
||||||
|
const { files } = useFileStore();
|
||||||
|
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||||
|
useToolProcessor("text-overlay");
|
||||||
|
|
||||||
|
const [text, setText] = useState("Your Text Here");
|
||||||
|
const [fontSize, setFontSize] = useState(48);
|
||||||
|
const [color, setColor] = useState("#FFFFFF");
|
||||||
|
const [position, setPosition] = useState<"top" | "center" | "bottom">("bottom");
|
||||||
|
const [backgroundBox, setBackgroundBox] = useState(false);
|
||||||
|
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||||
|
const [shadow, setShadow] = useState(true);
|
||||||
|
|
||||||
|
const handleProcess = () => {
|
||||||
|
processFiles(files, { text, fontSize, color, position, backgroundBox, backgroundColor, shadow });
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFile = files.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={8} max={200} value={fontSize} onChange={(e) => setFontSize(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Text Color</label>
|
||||||
|
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Position</label>
|
||||||
|
<select
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(e.target.value as "top" | "center" | "bottom")}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="top">Top</option>
|
||||||
|
<option value="center">Center</option>
|
||||||
|
<option value="bottom">Bottom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" checked={shadow} onChange={(e) => setShadow(e.target.checked)} className="rounded" />
|
||||||
|
Drop Shadow
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" checked={backgroundBox} onChange={(e) => setBackgroundBox(e.target.checked)} className="rounded" />
|
||||||
|
Background Box
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{backgroundBox && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Box Color</label>
|
||||||
|
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{originalSize != null && processedSize != null && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||||
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={!hasFile || processing || !text}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{processing ? "Processing..." : "Add Text"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
|
|
||||||
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
return localStorage.getItem("stirling-token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatermarkImageSettings() {
|
||||||
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||||
|
const [position, setPosition] = useState<Position>("bottom-right");
|
||||||
|
const [opacity, setOpacity] = useState(50);
|
||||||
|
const [scale, setScale] = useState(25);
|
||||||
|
const [watermarkFile, setWatermarkFile] = useState<File | null>(null);
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||||
|
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||||
|
const watermarkInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleProcess = async () => {
|
||||||
|
if (files.length === 0 || !watermarkFile) return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
setDownloadUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", files[0]);
|
||||||
|
formData.append("watermark", watermarkFile);
|
||||||
|
formData.append("settings", JSON.stringify({ position, opacity, scale }));
|
||||||
|
|
||||||
|
const res = await fetch("/api/v1/tools/watermark-image", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Processing failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
setJobId(result.jobId);
|
||||||
|
setProcessedUrl(result.downloadUrl);
|
||||||
|
setDownloadUrl(result.downloadUrl);
|
||||||
|
setOriginalSize(result.originalSize);
|
||||||
|
setProcessedSize(result.processedSize);
|
||||||
|
setSizes(result.originalSize, result.processedSize);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Processing failed");
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFile = files.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Watermark Image</label>
|
||||||
|
<input
|
||||||
|
ref={watermarkInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setWatermarkFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => watermarkInputRef.current?.click()}
|
||||||
|
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{watermarkFile ? watermarkFile.name : "Choose watermark image"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Position</label>
|
||||||
|
<select
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(e.target.value as Position)}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="center">Center</option>
|
||||||
|
<option value="top-left">Top Left</option>
|
||||||
|
<option value="top-right">Top Right</option>
|
||||||
|
<option value="bottom-left">Bottom Left</option>
|
||||||
|
<option value="bottom-right">Bottom Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Scale</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{scale}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={5} max={100} value={scale} onChange={(e) => setScale(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{originalSize != null && processedSize != null && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||||
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={!hasFile || !watermarkFile || processing}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{processing ? "Processing..." : "Apply Watermark"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||||
|
|
||||||
|
export function WatermarkTextSettings() {
|
||||||
|
const { files } = useFileStore();
|
||||||
|
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||||
|
useToolProcessor("watermark-text");
|
||||||
|
|
||||||
|
const [text, setText] = useState("Sample Watermark");
|
||||||
|
const [fontSize, setFontSize] = useState(48);
|
||||||
|
const [color, setColor] = useState("#000000");
|
||||||
|
const [opacity, setOpacity] = useState(50);
|
||||||
|
const [position, setPosition] = useState<Position>("center");
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
|
||||||
|
const handleProcess = () => {
|
||||||
|
processFiles(files, { text, fontSize, color, opacity, position, rotation });
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFile = files.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Watermark Text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={8} max={200} value={fontSize} onChange={(e) => setFontSize(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Color</label>
|
||||||
|
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Position</label>
|
||||||
|
<select
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(e.target.value as Position)}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="center">Center</option>
|
||||||
|
<option value="top-left">Top Left</option>
|
||||||
|
<option value="top-right">Top Right</option>
|
||||||
|
<option value="bottom-left">Bottom Left</option>
|
||||||
|
<option value="bottom-right">Bottom Right</option>
|
||||||
|
<option value="tiled">Tiled (Repeating)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs text-muted-foreground">Rotation</label>
|
||||||
|
<span className="text-xs font-mono text-foreground">{rotation}°</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={-180} max={180} value={rotation} onChange={(e) => setRotation(Number(e.target.value))} className="w-full mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
{originalSize != null && processedSize != null && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||||
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={!hasFile || processing || !text}
|
||||||
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{processing ? "Processing..." : "Add Watermark"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user