From 558f653380dfe62f36d159a0fbdc88bc24c974ca Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
+ This demo shows how to save and restore editor templates using the
+ editor's ref methods.
+
+ Transformations: {savedTemplate.length}
+
+ Schema Version: {TRANSFORMATION_STATE_VERSION}
+
+ Types:{" "}
+ {Array.from(
+ new Set(savedTemplate.map((t) => t.type))
+ ).join(", ")}
+
+ 💾 Persistent Storage: Templates are saved to localStorage, so they persist across page reloads!
+
+ Note: Template IDs are automatically generated on load to ensure uniqueness and enable reusability.
+ ImageKit Editor - Template Management Demo
+
+ ✓ Saved Template
+
+
+ 📋 View Template JSON
+
+
+ {JSON.stringify(savedTemplate, null, 2)}
+
+ 📖 How to use Template Features:
+
+
+
Types:{" "} - {Array.from( - new Set(savedTemplate.map((t) => t.type)) - ).join(", ")} + {Array.from(new Set(savedTemplate.map((t) => t.type))).join( + ", ", + )}
📖 How to use Template Features:
- Click "Open ImageKit Editor" and apply some transformations
- - Click the "Save Template" button in the editor header
+ -
+ Click the "Save Template" button in the editor
+ header
+
- Close the editor
-
- Click "Load Saved Template" - it will open the editor with all transformations restored
+ Click "Load Saved Template" - it will open the
+ editor with all transformations restored
+
+ -
+ Use "Clear Template" to remove the saved template
- - Use "Clear Template" to remove the saved template
-
- 💾 Persistent Storage: Templates are saved to localStorage, so they persist across page reloads!
+
+ 💾 Persistent Storage: Templates are saved to
+ localStorage, so they persist across page reloads!
- Note: Template IDs are automatically generated on load to ensure uniqueness and enable reusability.
+ Note: Template IDs are automatically generated on
+ load to ensure uniqueness and enable reusability.
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index d9bdf1b..89689e7 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -20,19 +20,19 @@ export interface ImageKitEditorRef {
* @param image - Image URL string or FileElement with metadata
*/
loadImage: (image: string | InputFileElement) => void
-
+
/**
* Loads multiple images into the editor
* @param images - Array of image URL strings or FileElements with metadata
*/
loadImages: (images: Array) => void
-
+
/**
* Switches the current active image
* @param imageSrc - URL of the image to set as current
*/
setCurrentImage: (imageSrc: string) => void
-
+
/**
* Gets the current editor template (transformation stack)
* @returns Array of transformation objects representing the template
@@ -46,7 +46,7 @@ export interface ImageKitEditorRef {
* ```
*/
getTemplate: () => Transformation[]
-
+
/**
* Loads a template (transformation stack) into the editor
* @param template - Array of transformation objects without the 'id' field
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 4a644c0..8e1fbc2 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"
+import { transformationFormatters, transformationSchema } from "./schema"
import type { Transformation } from "./store"
import { TRANSFORMATION_STATE_VERSION } from "./store"
-import { transformationFormatters, transformationSchema } from "./schema"
/**
* V1 Template Fixtures
@@ -313,7 +313,7 @@ function validateTransformation(t: Omit): {
if (!result.success) {
result.error.errors.forEach((err) => {
errors.push(
- `Schema validation failed for '${err.path.join(".")}': ${err.message}`
+ `Schema validation failed for '${err.path.join(".")}': ${err.message}`,
)
})
}
@@ -611,7 +611,7 @@ describe("Backward Compatibility - V1 Templates", () => {
}
// Remove id for storage
- const { id, ...forStorage } = withId
+ const { id: _id, ...forStorage } = withId
expect(forStorage.id).toBeUndefined()
// Add id back when loading
@@ -662,11 +662,13 @@ describe("Backward Compatibility - V1 Templates", () => {
const result = validateTransformation(invalid)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
- expect(result.errors.some((e) => e.includes("not found in current schema"))).toBe(true)
+ expect(
+ result.errors.some((e) => e.includes("not found in current schema")),
+ ).toBe(true)
})
it("should reject transformation with wrong type", () => {
- const invalid: any = {
+ const invalid: Record = {
key: "adjust-background",
name: "Background",
type: "wrong-type",
@@ -685,7 +687,7 @@ describe("Backward Compatibility - V1 Templates", () => {
name: "Corner Radius",
type: "transformation",
value: {
- radius: 999, // Should be an object with {radius: number}
+ radius: 999, // Should be an object with {radius: number}
},
version: "v1",
}
@@ -933,7 +935,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionX: "bw_div_2", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionX: "bw_div_2",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -944,7 +951,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionY: "bh_sub_100", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionY: "bh_sub_100",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -1227,7 +1239,7 @@ describe("Backward Compatibility - V1 Templates", () => {
unsharpenMaskAmount: 1.2,
unsharpenMaskThreshold: 0.1,
},
- version: "v1",
+ version: "v1",
}
const result = validateTransformation(template)
if (!result.valid) {
@@ -1561,7 +1573,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", positionX: "invalid_expr", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ positionX: "invalid_expr",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(false)
@@ -1605,7 +1622,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", lineHeight: "ih_mul_1.5", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ lineHeight: "ih_mul_1.5",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(true)
@@ -1627,7 +1649,12 @@ describe("Backward Compatibility - V1 Templates", () => {
key: "layers-text",
name: "Text",
type: "transformation",
- value: { text: "Hello", lineHeight: "not_valid", fontSize: 24, radius: 0 },
+ value: {
+ text: "Hello",
+ lineHeight: "not_valid",
+ fontSize: 24,
+ radius: 0,
+ },
version: "v1",
}
expect(validateTransformation(template).valid).toBe(false)
@@ -2138,7 +2165,11 @@ describe("Backward Compatibility - V1 Templates", () => {
})
it("should validate text layer with all alignment options", () => {
- const alignments: Array<"left" | "right" | "center"> = ["left", "right", "center"]
+ const alignments: Array<"left" | "right" | "center"> = [
+ "left",
+ "right",
+ "center",
+ ]
alignments.forEach((align) => {
const template: Omit = {
key: "layers-text",
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index e2c1628..2220a89 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -311,13 +311,13 @@ const useEditorStore = create()(
id: `transformation-${Date.now()}-${index}`,
version: TRANSFORMATION_STATE_VERSION,
}))
-
+
const visibleTransformations: Record = {}
transformationsWithIds.forEach((t) => {
visibleTransformations[t.id] = true
})
-
- set((state) => ({
+
+ set((state) => ({
transformations: transformationsWithIds,
visibleTransformations: {
...state.visibleTransformations,
From 9f91d943fa2d59bd02d197ed236540e9e90b5bde Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 15:31:34 +0530
Subject: [PATCH 03/64] feat: add test script to package.json
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index 9c4303c..5046f3a 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dev": "turbo run dev",
"start": "turbo run start",
"build": "turbo run build",
+ "test": "turbo run test",
"version": "yarn workspace @imagekit/editor version",
"package": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz",
"release": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz && yarn workspace @imagekit/editor publish",
From b6b2bdd2164addd2089c684b35b0d444e4e4502d Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 16:59:13 +0530
Subject: [PATCH 04/64] test: add coverage tests for background and resize/crop
field visibility logic
---
.../src/backward-compatibility.test.ts | 445 ++++++++++++++++
.../src/schema/field-config.test.ts | 496 ++++++++++++++++++
packages/imagekit-editor-dev/vite.config.ts | 10 +-
3 files changed, 949 insertions(+), 2 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/schema/field-config.test.ts
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 8e1fbc2..62799e9 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -2577,4 +2577,449 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(false)
})
})
+
+ describe("Height Validator Coverage", () => {
+ it("should reject invalid height expression", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 100,
+ height: "invalid_height_expr",
+ mode: "cm-pad_resize",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+
+
+ describe("Unsharpen Mask Error Coverage", () => {
+ it("should require sigma when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ // Missing sigma and other required fields
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require amount when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1.5,
+ // Missing amount
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require threshold when unsharpen mask is enabled", () => {
+ const template: Omit = {
+ key: "layers-image",
+ name: "Image Layer",
+ type: "transformation",
+ value: {
+ imageUrl: "overlay.png",
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1.5,
+ unsharpenMaskAmount: 1.2,
+ // Missing threshold
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+ describe("Background Gradient Auto Coverage", () => {
+ it("should validate background gradient with radial mode", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientMode: "radial",
+ backgroundGradientPaletteSize: "2",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate background gradient with linear mode", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientMode: "linear",
+ backgroundGradientPaletteSize: "4",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate manual background gradient", () => {
+ const template: Omit = {
+ key: "adjust-background",
+ name: "Background",
+ type: "transformation",
+ value: {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ backgroundGradient: {
+ type: "linear",
+ angle: "90",
+ stops: [
+ { color: "#FF0000", stopPoint: 0 },
+ { color: "#0000FF", stopPoint: 100 },
+ ],
+ },
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+ describe("Resize Mode Conversion Coverage", () => {
+ it("should validate c-at_max_enlarge mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "c-at_max_enlarge",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate c-force mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "c-force",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should validate c-at_max mode", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-at_max",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+
+
+
+
+ describe("Maintain Ratio Focus Validations", () => {
+ it("should validate maintain_ratio with anchor focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "anchor",
+ focusAnchor: "center",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require focusAnchor for maintain_ratio with anchor focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "anchor",
+ // Missing focusAnchor
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should validate maintain_ratio with object focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ focusObject: "person",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require focusObject for maintain_ratio with object focus", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ // Missing focusObject
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+ })
+
+ describe("Pad Resize Background Validation Errors", () => {
+ it("should require width when using blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ // width missing
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require height when using blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ // height missing
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require width when using generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ // width missing
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should require height when using generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ // height missing
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should pass validation with both dimensions for blurred background", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "blurred",
+ backgroundBlurIntensity: 10,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should pass validation with both dimensions for generative fill", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-pad_resize",
+ backgroundType: "generative_fill",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
+
+ describe("Final Coverage Gaps - Missing Validations", () => {
+ it("should reject aspect ratio without width or height", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ aspectRatio: "16-9",
+ // No width or height
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept aspect ratio with width", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ aspectRatio: "16-9",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should require at least one center coordinate for cm-extract with coordinates", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ // Missing both xc and yc
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept center coordinates with at least xc for cm-extract", () => {
+ const template: Omit = {
+ key: "resize_and_crop-resize_and_crop",
+ name: "Resize and Crop",
+ type: "transformation",
+ value: {
+ width: 800,
+ height: 600,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ xc: "400",
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+
+ it("should reject unsharpen mask with threshold = 0 as invalid", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ unsharpenMaskThreshold: 0, // Falsy value
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(false)
+ })
+
+ it("should accept unsharpen mask with valid positive threshold", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ unsharpenMaskThreshold: 0.05,
+ },
+ version: "v1",
+ }
+ expect(validateTransformation(template).valid).toBe(true)
+ })
+ })
})
diff --git a/packages/imagekit-editor-dev/src/schema/field-config.test.ts b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
new file mode 100644
index 0000000..4d7748e
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
@@ -0,0 +1,496 @@
+import { describe, expect, it } from "vitest"
+import { backgroundTransformations } from "./background"
+import {
+ getDefaultTransformationFromMode,
+ resizeAndCropTransformations,
+} from "./resizeAndCrop"
+
+describe("Field Configuration Tests", () => {
+ describe("Background Fields - Visibility Logic", () => {
+ describe("background field (color picker)", () => {
+ it("should be visible for root_image when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden for root_image when auto dominant is enabled", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should be visible for pad_resize when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "pad_resize",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be visible for pad_extract when type is color and auto is off", () => {
+ const field = backgroundTransformations.background({
+ transformationGroup: "background",
+ context: "pad_extract",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("backgroundGradientMode field", () => {
+ it("should be visible when type is gradient and auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradientMode({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when auto dominant is false", () => {
+ const field = backgroundTransformations.backgroundGradientMode({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+
+ describe("backgroundGradientPaletteSize field", () => {
+ it("should be visible when type is gradient and auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradientPaletteSize({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when background type is not gradient", () => {
+ const field = backgroundTransformations.backgroundGradientPaletteSize({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "color",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+
+ describe("backgroundGradient field (manual gradient)", () => {
+ it("should be visible when type is gradient and auto dominant is false", () => {
+ const field = backgroundTransformations.backgroundGradient({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should be hidden when auto dominant is true", () => {
+ const field = backgroundTransformations.backgroundGradient({
+ transformationGroup: "background",
+ context: "root_image",
+ })
+
+ const visible = field.isVisible?.({
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ })
+
+ expect(visible).toBe(false)
+ })
+ })
+ })
+
+ describe("Resize and Crop Fields - Visibility and Helpers", () => {
+ describe("coordinate field visibility", () => {
+ it("should show x field for topleft coordinates in extract mode", () => {
+ const xField = resizeAndCropTransformations.find((f) => f.name === "x")
+
+ const visible = xField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide x field when coordinate method is not topleft", () => {
+ const xField = resizeAndCropTransformations.find((f) => f.name === "x")
+
+ const visible = xField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show y field for topleft coordinates in extract mode", () => {
+ const yField = resizeAndCropTransformations.find((f) => f.name === "y")
+
+ const visible = yField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show xc field for center coordinates in extract mode", () => {
+ const xcField = resizeAndCropTransformations.find((f) => f.name === "xc")
+
+ const visible = xcField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show yc field for center coordinates in extract mode", () => {
+ const ycField = resizeAndCropTransformations.find((f) => f.name === "yc")
+
+ const visible = ycField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ coordinateMethod: "center",
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("focus field visibility", () => {
+ it("should show focusAnchor when focus is anchor", () => {
+ const focusAnchorField = resizeAndCropTransformations.find(
+ (f) => f.name === "focusAnchor",
+ )
+
+ const visible = focusAnchorField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "anchor",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show focusObject when focus is object", () => {
+ const focusObjectField = resizeAndCropTransformations.find(
+ (f) => f.name === "focusObject",
+ )
+
+ const visible = focusObjectField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "object",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show coordinateMethod when focus is coordinates", () => {
+ const coordinateMethodField = resizeAndCropTransformations.find(
+ (f) => f.name === "coordinateMethod",
+ )
+
+ const visible = coordinateMethodField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "coordinates",
+ })
+
+ expect(visible).toBe(true)
+ })
+ })
+
+ describe("mode-specific field visibility", () => {
+ it("should show focus field for extract mode", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for extract mode
+ const extractFocusField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ }),
+ )
+
+ expect(extractFocusField).toBeDefined()
+ })
+
+ it("should show focus field for maintain_ratio crop", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for maintain_ratio mode
+ const maintainRatioFocusField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-maintain_ratio",
+ }),
+ )
+
+ expect(maintainRatioFocusField).toBeDefined()
+ })
+ })
+ })
+
+ describe("Helper Functions - getDefaultTransformationfromMode", () => {
+ it("should return cropMode pad_resize for cm-pad_resize", () => {
+ const result = getDefaultTransformationFromMode("cm-pad_resize")
+ expect(result).toEqual({ cropMode: "pad_resize" })
+ })
+
+ it("should return cropMode extract for cm-extract", () => {
+ const result = getDefaultTransformationFromMode("cm-extract")
+ expect(result).toEqual({ cropMode: "extract" })
+ })
+
+ it("should return cropMode pad_extract for cm-pad_extract", () => {
+ const result = getDefaultTransformationFromMode("cm-pad_extract")
+ expect(result).toEqual({ cropMode: "pad_extract" })
+ })
+
+ it("should return crop maintain_ratio for c-maintain_ratio", () => {
+ const result = getDefaultTransformationFromMode("c-maintain_ratio")
+ expect(result).toEqual({ crop: "maintain_ratio" })
+ })
+
+ it("should return crop force for c-force", () => {
+ const result = getDefaultTransformationFromMode("c-force")
+ expect(result).toEqual({ crop: "force" })
+ })
+
+ it("should return crop at_max for c-at_max", () => {
+ const result = getDefaultTransformationFromMode("c-at_max")
+ expect(result).toEqual({ crop: "at_max" })
+ })
+
+ it("should return crop at_max_enlarge for c-at_max_enlarge", () => {
+ const result = getDefaultTransformationFromMode("c-at_max_enlarge")
+ expect(result).toEqual({ crop: "at_max_enlarge" })
+ })
+
+ it("should return crop at_least for c-at_least", () => {
+ const result = getDefaultTransformationFromMode("c-at_least")
+ expect(result).toEqual({ crop: "at_least" })
+ })
+
+ it("should return empty object for unknown mode", () => {
+ const result = getDefaultTransformationFromMode("unknown-mode")
+ expect(result).toEqual({})
+ })
+ })
+
+ describe("Additional Field Visibility Coverage", () => {
+ it("should show DPR field when enabled and width exists", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: true,
+ width: 100,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show DPR field when enabled and height exists", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: true,
+ height: 100,
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide DPR field when not enabled", () => {
+ const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+
+ const visible = dprField?.isVisible?.({
+ dprEnabled: false,
+ width: 100,
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show zoom field for face focus in extract mode", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "face",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should show zoom field for object focus in maintain_ratio", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-maintain_ratio",
+ focus: "object",
+ })
+
+ expect(visible).toBe(true)
+ })
+
+ it("should hide zoom field for anchor focus", () => {
+ const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+
+ const visible = zoomField?.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-extract",
+ focus: "anchor",
+ })
+
+ expect(visible).toBe(false)
+ })
+
+ it("should show focus field for c-force mode", () => {
+ const focusFields = resizeAndCropTransformations.filter(
+ (f) => f.name === "focus",
+ )
+ // Find the one for force mode (has only auto option)
+ const forceField = focusFields.find((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "c-force",
+ }),
+ )
+
+ expect(forceField).toBeDefined()
+ expect(forceField?.fieldProps?.options).toHaveLength(1)
+ expect(forceField?.fieldProps?.options?.[0].value).toBe("auto")
+ })
+
+ it("should test pad_resize background field wrapper", () => {
+ // Find background fields that are visible for pad_resize
+ const backgroundFields = resizeAndCropTransformations.filter(
+ (f) =>
+ f.transformationGroup === "background" ||
+ f.name === "backgroundType" ||
+ f.name === "background",
+ )
+
+ // At least one should be visible for pad_resize with dimensions
+ const visibleForPadResize = backgroundFields.some((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-pad_resize",
+ backgroundType: "color",
+ }),
+ )
+
+ expect(visibleForPadResize).toBe(true)
+ })
+
+ it("should test pad_extract background field wrapper", () => {
+ // Find background fields that are visible for pad_extract
+ const backgroundFields = resizeAndCropTransformations.filter(
+ (f) =>
+ f.transformationGroup === "background" ||
+ f.name === "backgroundType" ||
+ f.name === "background",
+ )
+
+ // At least one should be visible for pad_extract with dimensions
+ const visibleForPadExtract = backgroundFields.some((f) =>
+ f.isVisible?.({
+ width: 100,
+ height: 100,
+ mode: "cm-pad_extract",
+ backgroundType: "color",
+ }),
+ )
+
+ expect(visibleForPadExtract).toBe(true)
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 15d7a68..70de203 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -24,12 +24,18 @@ export default defineConfig({
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
- include: ["src/**/*.{ts,tsx}"],
+ include: ["src/schema/**/*.{ts,tsx}"],
exclude: [
"src/**/*.{test,spec}.{ts,tsx}",
- "src/index.tsx",
"node_modules/**",
],
+ thresholds: {
+ // Only enforced on src/schema files - focusing on validation logic
+ lines: 85, // Realistic threshold given UI visibility code
+ branches: 85,
+ statements: 85,
+ perFile: false, // Global threshold across all schema files
+ },
},
},
build: {
From e7044a2f30fac5aa008ab604d975c96e56b7d4cf Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 16:59:29 +0530
Subject: [PATCH 05/64] ci: update test command to run coverage tests
---
.github/workflows/ci.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 0756460..3ae2a6c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -26,7 +26,7 @@ jobs:
run: |
yarn install --frozen-lockfile
yarn lint
- yarn test
+ yarn test:coverage
yarn package
env:
CI: true
From aee07842deaae4aa1a7f3b0d6ad885c51e9026fb Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:07:34 +0530
Subject: [PATCH 06/64] test: add validation tests for backward compatibility
and transformation formatters
---
.../src/backward-compatibility.test.ts | 83 +++++
.../src/schema/formatters.test.ts | 320 ++++++++++++++++++
.../src/schema/transformation.ts | 19 --
3 files changed, 403 insertions(+), 19 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/schema/formatters.test.ts
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index 62799e9..eb4eb29 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -3005,6 +3005,25 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(false)
})
+ it("should reject unsharpen mask with missing threshold", () => {
+ const template: Omit = {
+ key: "adjust-unsharpen-mask",
+ name: "Unsharpen Mask",
+ type: "transformation",
+ value: {
+ unsharpenMask: true,
+ unsharpenMaskRadius: 2,
+ unsharpenMaskSigma: 1,
+ unsharpenMaskAmount: 0.5,
+ // Missing unsharpenMaskThreshold entirely
+ },
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("Threshold"))).toBe(true)
+ })
+
it("should accept unsharpen mask with valid positive threshold", () => {
const template: Omit = {
key: "adjust-unsharpen-mask",
@@ -3022,4 +3041,68 @@ describe("Backward Compatibility - V1 Templates", () => {
expect(validateTransformation(template).valid).toBe(true)
})
})
+
+ describe("Empty Transformation Validation - At Least One Value Required", () => {
+ it("should reject contrast transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ })
+
+ it("should reject shadow transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-shadow",
+ name: "Shadow",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ })
+
+ it("should reject grayscale transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-grayscale",
+ name: "Grayscale",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+
+ it("should reject radius transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-radius",
+ name: "Radius",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+
+ it("should reject trim transformation with no values", () => {
+ const template: Omit = {
+ key: "adjust-trim",
+ name: "Trim",
+ type: "transformation",
+ value: {},
+ version: "v1",
+ }
+ const result = validateTransformation(template)
+ expect(result.valid).toBe(false)
+ })
+ })
})
diff --git a/packages/imagekit-editor-dev/src/schema/formatters.test.ts b/packages/imagekit-editor-dev/src/schema/formatters.test.ts
new file mode 100644
index 0000000..557fc95
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/schema/formatters.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from "vitest"
+import { transformationFormatters } from "./index"
+
+describe("Transformation Formatters", () => {
+ describe("background formatter", () => {
+ it("should format color background with dominant auto", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "color",
+ backgroundDominantAuto: true,
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("dominant")
+ })
+
+ it("should format gradient background with auto dominant", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ backgroundGradientPaletteSize: "4",
+ backgroundGradientMode: "contrast",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("gradient_contrast_4")
+ })
+
+ it("should format gradient background with default values", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: true,
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("gradient_dominant_2")
+ })
+
+ it("should format manual gradient background", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "gradient",
+ backgroundGradientAutoDominant: false,
+ backgroundGradient: {
+ from: "#FF0000",
+ to: "#0000FF",
+ direction: "top",
+ stopPoint: 50,
+ },
+ },
+ transforms,
+ )
+ expect(transforms.raw).toContain("e-gradient")
+ expect(transforms.raw).toContain("from-FF0000")
+ expect(transforms.raw).toContain("to-0000FF")
+ })
+
+ it("should format blurred background with auto intensity", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "auto",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_auto")
+ })
+
+ it("should format blurred background with auto intensity and brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "auto",
+ backgroundBlurBrightness: "50",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_auto_50")
+ })
+
+ it("should format blurred background with numeric intensity", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10")
+ })
+
+ it("should format blurred background with intensity and brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ backgroundBlurBrightness: "20",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10_20")
+ })
+
+ it("should handle negative blur brightness", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "10",
+ backgroundBlurBrightness: "-20",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred_10_N20")
+ })
+
+ it("should format generative fill background without prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("genfill")
+ })
+
+ it("should format generative fill with simple text prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ backgroundGenerativeFill: "beach",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("genfill-prompt-beach")
+ })
+
+ it("should format generative fill with complex prompt", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "generative_fill",
+ backgroundGenerativeFill: "beach with palm trees!",
+ },
+ transforms,
+ )
+ expect(transforms.background).toContain("genfill-prompte-")
+ })
+
+ it("should format color background with manual color", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "color",
+ backgroundDominantAuto: false,
+ background: "#FF5733",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("FF5733")
+ })
+
+ it("should default to blurred when intensity is invalid", () => {
+ const transforms: Record = {}
+ transformationFormatters.background(
+ {
+ backgroundType: "blurred",
+ backgroundBlurIntensity: "invalid",
+ },
+ transforms,
+ )
+ expect(transforms.background).toBe("blurred")
+ })
+ })
+
+ describe("focus formatter", () => {
+ it("should format focus with anchor", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "anchor",
+ focusAnchor: "top_left",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("top_left")
+ })
+
+ it("should format focus with object", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "object",
+ focusObject: "face",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("face")
+ })
+
+ it("should format focus with auto", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "auto",
+ },
+ transforms,
+ )
+ expect(transforms.focus).toBe("auto")
+ })
+
+ it("should format focus with center coordinates", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "coordinates",
+ coordinateMethod: "center",
+ xc: "100",
+ yc: "200",
+ },
+ transforms,
+ )
+ expect(transforms.xc).toBe("100")
+ expect(transforms.yc).toBe("200")
+ })
+
+ it("should format focus with topleft coordinates", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "coordinates",
+ coordinateMethod: "topleft",
+ x: "50",
+ y: "75",
+ },
+ transforms,
+ )
+ expect(transforms.x).toBe("50")
+ expect(transforms.y).toBe("75")
+ })
+
+ it("should format focus with zoom", () => {
+ const transforms: Record = {}
+ transformationFormatters.focus(
+ {
+ focus: "auto",
+ zoom: 150,
+ },
+ transforms,
+ )
+ expect(transforms.zoom).toBe(1.5)
+ })
+ })
+
+ describe("shadow formatter", () => {
+ it("should format shadow with all parameters", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowBlur: 10,
+ shadowSaturation: 50,
+ shadowOffsetX: 5,
+ shadowOffsetY: 8,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBe("bl-10_st-50_x-5_y-8")
+ })
+
+ it("should skip shadow when disabled", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: false,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBeUndefined()
+ })
+
+ it("should handle negative shadow offsets", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowOffsetX: -5,
+ shadowOffsetY: -10,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toContain("x-N5")
+ expect(transforms.shadow).toContain("y-N10")
+ })
+
+ it("should format shadow with only blur", () => {
+ const transforms: Record = {}
+ transformationFormatters.shadow(
+ {
+ shadow: true,
+ shadowBlur: 15,
+ },
+ transforms,
+ )
+ expect(transforms.shadow).toBe("bl-15")
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts
index e188df5..6b05c4e 100644
--- a/packages/imagekit-editor-dev/src/schema/transformation.ts
+++ b/packages/imagekit-editor-dev/src/schema/transformation.ts
@@ -146,25 +146,6 @@ export const commonNumberAndExpressionValidator = z
})
})
-const overlayBlockExpr = z
- .string()
- .regex(/^(?:bh|bw|bar)_(?:add|sub|mul|div|mod|pow)_(?:\d+(\.\d{1,2})?)$/, {
- message: "String must be a valid expression string.",
- })
-
-export const overlayBlockExprValidator = z.any().superRefine((val, ctx) => {
- if (commonNumber.safeParse(val).success) {
- return
- }
- if (overlayBlockExpr.safeParse(val).success) {
- return
- }
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Must be a positive number or a valid expression string.",
- })
-})
-
const lineHeightInteger = z.coerce.string().regex(/^\d+$/)
const lineHeightExpr = z
From ed70e312f8ea969b40b3778bbe24976350322797 Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:08:04 +0530
Subject: [PATCH 07/64] refactor: clean up code formatting and improve
readability in tests and configuration
---
.../src/backward-compatibility.test.ts | 16 ++++------
.../src/schema/field-config.test.ts | 32 ++++++++++++++-----
packages/imagekit-editor-dev/vite.config.ts | 5 +--
3 files changed, 32 insertions(+), 21 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
index eb4eb29..7807c90 100644
--- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
+++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts
@@ -2595,8 +2595,6 @@ describe("Backward Compatibility - V1 Templates", () => {
})
})
-
-
describe("Unsharpen Mask Error Coverage", () => {
it("should require sigma when unsharpen mask is enabled", () => {
const template: Omit = {
@@ -2752,10 +2750,6 @@ describe("Backward Compatibility - V1 Templates", () => {
})
})
-
-
-
-
describe("Maintain Ratio Focus Validations", () => {
it("should validate maintain_ratio with anchor focus", () => {
const template: Omit = {
@@ -3021,7 +3015,7 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("Threshold"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("Threshold"))).toBe(true)
})
it("should accept unsharpen mask with valid positive threshold", () => {
@@ -3053,7 +3047,9 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("At least one value"))).toBe(
+ true,
+ )
})
it("should reject shadow transformation with no values", () => {
@@ -3066,7 +3062,9 @@ describe("Backward Compatibility - V1 Templates", () => {
}
const result = validateTransformation(template)
expect(result.valid).toBe(false)
- expect(result.errors?.some(e => e.includes("At least one value"))).toBe(true)
+ expect(result.errors?.some((e) => e.includes("At least one value"))).toBe(
+ true,
+ )
})
it("should reject grayscale transformation with no values", () => {
diff --git a/packages/imagekit-editor-dev/src/schema/field-config.test.ts b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
index 4d7748e..80882eb 100644
--- a/packages/imagekit-editor-dev/src/schema/field-config.test.ts
+++ b/packages/imagekit-editor-dev/src/schema/field-config.test.ts
@@ -201,7 +201,9 @@ describe("Field Configuration Tests", () => {
})
it("should show xc field for center coordinates in extract mode", () => {
- const xcField = resizeAndCropTransformations.find((f) => f.name === "xc")
+ const xcField = resizeAndCropTransformations.find(
+ (f) => f.name === "xc",
+ )
const visible = xcField?.isVisible?.({
width: 100,
@@ -215,7 +217,9 @@ describe("Field Configuration Tests", () => {
})
it("should show yc field for center coordinates in extract mode", () => {
- const ycField = resizeAndCropTransformations.find((f) => f.name === "yc")
+ const ycField = resizeAndCropTransformations.find(
+ (f) => f.name === "yc",
+ )
const visible = ycField?.isVisible?.({
width: 100,
@@ -360,7 +364,9 @@ describe("Field Configuration Tests", () => {
describe("Additional Field Visibility Coverage", () => {
it("should show DPR field when enabled and width exists", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: true,
@@ -371,7 +377,9 @@ describe("Field Configuration Tests", () => {
})
it("should show DPR field when enabled and height exists", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: true,
@@ -382,7 +390,9 @@ describe("Field Configuration Tests", () => {
})
it("should hide DPR field when not enabled", () => {
- const dprField = resizeAndCropTransformations.find((f) => f.name === "dpr")
+ const dprField = resizeAndCropTransformations.find(
+ (f) => f.name === "dpr",
+ )
const visible = dprField?.isVisible?.({
dprEnabled: false,
@@ -393,7 +403,9 @@ describe("Field Configuration Tests", () => {
})
it("should show zoom field for face focus in extract mode", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
@@ -406,7 +418,9 @@ describe("Field Configuration Tests", () => {
})
it("should show zoom field for object focus in maintain_ratio", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
@@ -419,7 +433,9 @@ describe("Field Configuration Tests", () => {
})
it("should hide zoom field for anchor focus", () => {
- const zoomField = resizeAndCropTransformations.find((f) => f.name === "zoom")
+ const zoomField = resizeAndCropTransformations.find(
+ (f) => f.name === "zoom",
+ )
const visible = zoomField?.isVisible?.({
width: 100,
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 70de203..84f1f2d 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -25,10 +25,7 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/schema/**/*.{ts,tsx}"],
- exclude: [
- "src/**/*.{test,spec}.{ts,tsx}",
- "node_modules/**",
- ],
+ exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"],
thresholds: {
// Only enforced on src/schema files - focusing on validation logic
lines: 85, // Realistic threshold given UI visibility code
From 2aa122fb596687ef6999874c74ec75236faf8755 Mon Sep 17 00:00:00 2001
From: Manu Chaudhary
Date: Tue, 10 Mar 2026 17:09:20 +0530
Subject: [PATCH 08/64] fix: increase coverage thresholds for lines, branches,
and statements in Vite config
---
packages/imagekit-editor-dev/vite.config.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts
index 84f1f2d..40053ea 100644
--- a/packages/imagekit-editor-dev/vite.config.ts
+++ b/packages/imagekit-editor-dev/vite.config.ts
@@ -28,9 +28,9 @@ export default defineConfig({
exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"],
thresholds: {
// Only enforced on src/schema files - focusing on validation logic
- lines: 85, // Realistic threshold given UI visibility code
- branches: 85,
- statements: 85,
+ lines: 90, // Realistic threshold given UI visibility code
+ branches: 90,
+ statements: 90,
perFile: false, // Global threshold across all schema files
},
},
From 1dd73d4028dec77563f997c193d6b997a690a596 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Mon, 16 Mar 2026 17:41:26 +0530
Subject: [PATCH 09/64] feat: working commit for auto-save templates with
storage provider interfaces
---
examples/react-example/src/index.tsx | 37 +-
.../src/ImageKitEditor.tsx | 92 ++++-
.../components/common/CheckboxCardField.tsx | 15 +-
.../src/components/editor/layout.tsx | 5 +
.../components/header/TemplateNameInput.tsx | 96 +++++
.../src/components/header/TemplateStatus.tsx | 173 +++++++++
.../components/header/TemplatesDropdown.tsx | 351 ++++++++++++++++++
.../src/components/header/index.tsx | 38 +-
.../src/context/TemplateStorageContext.tsx | 24 ++
.../src/hooks/useAutoSaveTemplate.ts | 85 +++++
.../src/hooks/useSaveTemplate.ts | 48 +++
packages/imagekit-editor-dev/src/index.tsx | 6 +
.../imagekit-editor-dev/src/schema/index.ts | 23 +-
.../imagekit-editor-dev/src/storage/index.ts | 6 +
.../src/storage/localStorage-provider.ts | 91 +++++
.../imagekit-editor-dev/src/storage/types.ts | 21 ++
packages/imagekit-editor-dev/src/store.ts | 93 ++++-
17 files changed, 1116 insertions(+), 88 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
create mode 100644 packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
create mode 100644 packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
create mode 100644 packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/index.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/types.ts
diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx
index a1b3bef..bd21603 100644
--- a/examples/react-example/src/index.tsx
+++ b/examples/react-example/src/index.tsx
@@ -4,7 +4,6 @@ import {
type ImageKitEditorProps,
type ImageKitEditorRef,
TRANSFORMATION_STATE_VERSION,
- type Transformation,
} from "@imagekit/editor"
import { PiDownload } from "@react-icons/all-files/pi/PiDownload"
import React, { useCallback, useEffect } from "react"
@@ -43,28 +42,6 @@ function App() {
}
}, [open, shouldLoadTemplate, savedTemplate])
- /**
- * Save the current editor template
- */
- const handleSaveTemplate = useCallback(() => {
- const template = ref.current?.getTemplate()
- if (template) {
- // Remove the 'id' field from each transformation for storage
- const templateToSave = template.map(
- ({ id, ...rest }: Transformation) => rest,
- )
- setSavedTemplate(templateToSave)
- // Also save to localStorage for persistence
- localStorage.setItem("editorTemplate", JSON.stringify(templateToSave))
- console.log("Saved template:", templateToSave)
- alert(
- `✅ Saved template with ${templateToSave.length} transformation(s)!`,
- )
- } else {
- alert("⚠️ No transformations to save")
- }
- }, [])
-
/**
* Load previously saved template
*/
@@ -141,22 +118,13 @@ function App() {
exportOptions: [
{
type: "button",
- label: "Export Images",
+ label: "Export",
icon: ,
isVisible: true,
onClick: (images, currentImage) => {
console.log("Export images:", images, currentImage)
},
},
- {
- type: "button",
- label: "Save Template",
- icon: ,
- isVisible: true,
- onClick: () => {
- handleSaveTemplate()
- },
- },
// {
// type: "menu",
// label: "Export",
@@ -180,8 +148,9 @@ function App() {
console.log("Signed URL", request.url)
return Promise.resolve(request.url)
},
+ storageProvider: "localStorage",
})
- }, [handleAddImage, handleSaveTemplate])
+ }, [handleAddImage])
const toggle = () => {
setOpen((prev: boolean) => !prev)
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index 89689e7..4f0b836 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -1,9 +1,20 @@
import { ChakraProvider, theme as defaultTheme } from "@chakra-ui/react"
import type { Dict } from "@chakra-ui/utils"
import merge from "lodash/merge"
-import React, { forwardRef, useImperativeHandle } from "react"
+import React, {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+} from "react"
import { EditorLayout, EditorWrapper } from "./components/editor"
import type { HeaderProps } from "./components/header"
+import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
+import {
+ createLocalStorageProvider,
+ type LocalStorageProviderOptions,
+ type TemplateStorageProvider,
+} from "./storage"
import {
type FocusObjects,
type InputFileElement,
@@ -57,6 +68,12 @@ export interface ImageKitEditorRef {
* ```
*/
loadTemplate: (template: Omit[]) => void
+
+ /**
+ * Explicitly saves the current template to the configured storage provider.
+ * No-op if no storage provider is configured.
+ */
+ saveTemplate: () => Promise
}
interface EditorProps {
@@ -67,13 +84,24 @@ interface EditorProps {
exportOptions?: HeaderProps["exportOptions"]
focusObjects?: ReadonlyArray
onClose: (args: { dirty: boolean; destroy: () => void }) => void
+ storageProvider?: "localStorage" | "library"
+ libraryStorage?: TemplateStorageProvider
+ localStorageKeys?: LocalStorageProviderOptions
}
function ImageKitEditorImpl(
props: EditorProps,
ref: React.Ref,
) {
- const { theme, initialImages, signer, focusObjects } = props
+ const {
+ theme,
+ initialImages,
+ signer,
+ focusObjects,
+ storageProvider,
+ libraryStorage,
+ localStorageKeys,
+ } = props
const {
addImage,
addImages,
@@ -84,6 +112,40 @@ function ImageKitEditorImpl(
loadTemplate,
} = useEditorStore()
+ const resolvedProvider = useMemo(() => {
+ if (storageProvider === "localStorage") {
+ return createLocalStorageProvider(localStorageKeys)
+ }
+ if (storageProvider === "library" && libraryStorage) {
+ return libraryStorage
+ }
+ return null
+ }, [storageProvider, libraryStorage, localStorageKeys])
+
+ const saveTemplateImperative = useCallback(async () => {
+ if (!resolvedProvider) return
+ const state = useEditorStore.getState()
+ const { setSyncStatus, setTemplateId, setTemplateName } = state
+ setSyncStatus("saving")
+ try {
+ const saved = await resolvedProvider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setTemplateId(saved.id)
+ setTemplateName(saved.name)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save template",
+ )
+ }
+ }, [resolvedProvider])
+
const handleOnClose = () => {
const dirty = transformations.length > 0
props.onClose({ dirty, destroy })
@@ -116,8 +178,16 @@ function ImageKitEditorImpl(
setCurrentImage,
getTemplate: () => transformations,
loadTemplate,
+ saveTemplate: saveTemplateImperative,
}),
- [addImage, addImages, setCurrentImage, transformations, loadTemplate],
+ [
+ addImage,
+ addImages,
+ setCurrentImage,
+ transformations,
+ loadTemplate,
+ saveTemplateImperative,
+ ],
)
const mergedThemes = merge(defaultTheme, themeOverrides, theme)
@@ -125,13 +195,15 @@ function ImageKitEditorImpl(
return (
-
-
-
+
+
+
+
+
)
diff --git a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
index 9e043d8..6f56a95 100644
--- a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx
@@ -30,13 +30,15 @@ const toggleValue = (
v: string,
max?: number,
): string[] => {
- const set = new Set(current)
+ // Guard: a stored string must never be spread into characters via new Set(string).
+ const currentArray = Array.isArray(current) ? current : []
+ const set = new Set(currentArray)
if (set.has(v)) {
set.delete(v)
return Array.from(set)
}
// add
- if (typeof max === "number" && current.length >= max) return current
+ if (typeof max === "number" && currentArray.length >= max) return currentArray
set.add(v)
return Array.from(set)
}
@@ -52,8 +54,9 @@ export const CheckboxCardField: React.FC = ({
const selectedBg = useColorModeValue("blue.50", "blue.900")
const selectedBorder = useColorModeValue("blue.400", "blue.300")
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
+ const safeValue = Array.isArray(value) ? value : []
const isMaxed =
- typeof maxSelections === "number" && value.length >= maxSelections
+ typeof maxSelections === "number" && safeValue.length >= maxSelections
const handleKeyDown = (
e: React.KeyboardEvent,
@@ -63,7 +66,7 @@ export const CheckboxCardField: React.FC = ({
if (disabled) return
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
- onChange(toggleValue(value, v, maxSelections))
+ onChange(toggleValue(safeValue, v, maxSelections))
}
}
@@ -84,7 +87,7 @@ export const CheckboxCardField: React.FC = ({
}}
>
{options.map((opt) => {
- const isChecked = value.includes(opt.value)
+ const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled || (!isChecked && isMaxed)
return (
// biome-ignore lint/a11y/useSemanticElements:
@@ -97,7 +100,7 @@ export const CheckboxCardField: React.FC = ({
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (disabled) return
- onChange(toggleValue(value, opt.value, maxSelections))
+ onChange(toggleValue(safeValue, opt.value, maxSelections))
}}
onKeyDown={(e) => handleKeyDown(e, opt.value, disabled)}
cursor={disabled ? "not-allowed" : "pointer"}
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index c724450..f0a0fec 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,7 @@
import { Flex } from "@chakra-ui/react"
import { useState } from "react"
+import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
+import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
import { Sidebar } from "../sidebar"
import { ActionBar } from "./ActionBar"
@@ -16,6 +18,9 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
+ useAutoSaveTemplate()
+ useSaveTemplate()
+
return (
<>
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
new file mode 100644
index 0000000..1304299
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -0,0 +1,96 @@
+import { Input } from "@chakra-ui/react"
+import React, { useEffect, useRef, useState } from "react"
+import { useEditorStore } from "../../store"
+
+const UNTITLED = "Untitled Template"
+
+export function TemplateNameInput() {
+ const templateName = useEditorStore((s) => s.templateName)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const setTemplateName = useEditorStore((s) => s.setTemplateName)
+
+ const [localValue, setLocalValue] = useState(templateName)
+ const localValueRef = useRef(localValue)
+ const inputRef = useRef(null)
+ const isFocusedRef = useRef(false)
+ const prevIsPristineRef = useRef(isPristine)
+
+ localValueRef.current = localValue
+
+ // Sync from store when not focused so external changes (e.g. loading a
+ // template from the dropdown) update the input without overwriting in-progress edits.
+ useEffect(() => {
+ if (!isFocusedRef.current) {
+ setLocalValue(templateName)
+ }
+ }, [templateName])
+
+ // Focus the input whenever a new template is created (isPristine transitions
+ // false → true, which only happens via resetToNewTemplate).
+ useEffect(() => {
+ const wasPristine = prevIsPristineRef.current
+ prevIsPristineRef.current = isPristine
+ if (isPristine && !wasPristine) {
+ inputRef.current?.focus()
+ }
+ }, [isPristine])
+
+ const commit = () => {
+ const trimmed = localValueRef.current.trim()
+ const finalName = trimmed || UNTITLED
+ if (!trimmed) {
+ setLocalValue(UNTITLED)
+ }
+ // setTemplateName only marks isPristine:false when the name actually changed,
+ // which is what gates the auto-save in useAutoSaveTemplate.
+ setTemplateName(finalName)
+ }
+
+ const handleFocus = () => {
+ isFocusedRef.current = true
+ inputRef.current?.select()
+ }
+
+ const handleBlur = () => {
+ isFocusedRef.current = false
+ commit()
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ inputRef.current?.blur()
+ }
+ if (e.key === "Escape") {
+ setLocalValue(templateName)
+ inputRef.current?.blur()
+ }
+ }
+
+ const isDefault = localValue === UNTITLED
+
+ return (
+ setLocalValue(e.target.value)}
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ onKeyDown={handleKeyDown}
+ variant="unstyled"
+ fontWeight="medium"
+ fontSize="md"
+ color={isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"}
+ placeholder={UNTITLED}
+ _placeholder={{ color: "editorBattleshipGrey.500" }}
+ width="auto"
+ minW="10rem"
+ maxW="22rem"
+ px="2"
+ py="1"
+ borderRadius="md"
+ _hover={{ bg: "editorGray.200" }}
+ _focus={{ bg: "editorGray.200", outline: "none", boxShadow: "none" }}
+ cursor="text"
+ />
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
new file mode 100644
index 0000000..07656e4
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -0,0 +1,173 @@
+import {
+ Box,
+ Flex,
+ Icon,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ Tooltip,
+} from "@chakra-ui/react"
+import { IoMdCloudDone } from "@react-icons/all-files/io/IoMdCloudDone"
+import { MdSync } from "@react-icons/all-files/md/MdSync"
+import { MdSyncProblem } from "@react-icons/all-files/md/MdSyncProblem"
+import React, { useEffect, useRef, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useEditorStore } from "../../store"
+
+const NOTIFICATION_DURATION_MS = 3000
+
+export function TemplateStatus() {
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+ const storageError = useEditorStore((s) => s.storageError)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const provider = useTemplateStorage()
+
+ const [notificationVisible, setNotificationVisible] = useState(false)
+ const [lastSyncResult, setLastSyncResult] = useState<
+ "success" | "error" | null
+ >(null)
+ const timerRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+
+ if (syncStatus === "saving") {
+ setNotificationVisible(true)
+ } else if (syncStatus === "saved") {
+ setLastSyncResult("success")
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ } else if (syncStatus === "unsaved") {
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ } else if (syncStatus === "error") {
+ setLastSyncResult("error")
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ }
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [syncStatus])
+
+ if (!provider || isPristine) return null
+
+ const providerName = provider.getProviderName()
+
+ // "Saving…" is a transient text-only state — no icon yet
+ if (notificationVisible && syncStatus === "saving") {
+ return (
+
+ Saving…
+
+ )
+ }
+
+ // Resolve the icon and label for the current state.
+ // When notification is visible, we show the icon + inline text.
+ // When notification fades, we show the icon alone (persistent/interactive).
+ // The icon wrapper is always structurally identical so its position never shifts.
+ let activeIcon: typeof IoMdCloudDone
+ let activeColor: string
+ let notifText: string | null = null
+ let isInteractive = false
+
+ if (notificationVisible) {
+ if (syncStatus === "saved") {
+ activeIcon = IoMdCloudDone
+ activeColor = "green.500"
+ notifText = `Saved to ${providerName}`
+ } else if (syncStatus === "unsaved") {
+ activeIcon = MdSync
+ activeColor = "editorBattleshipGrey.500"
+ notifText = "Unsaved local changes"
+ } else if (syncStatus === "error") {
+ activeIcon = MdSyncProblem
+ activeColor = "yellow.600"
+ notifText = "Save failed"
+ } else {
+ return null
+ }
+ } else {
+ if (lastSyncResult === null) return null
+ activeIcon = lastSyncResult === "success" ? IoMdCloudDone : MdSyncProblem
+ activeColor = lastSyncResult === "success" ? "green.500" : "yellow.600"
+ isInteractive = true
+ }
+
+ const popupTitle =
+ lastSyncResult === "success" ? "All changes saved" : "Sync failed"
+ const popupBody =
+ lastSyncResult === "success"
+ ? `Your changes are synced to ${providerName} successfully.`
+ : (storageError ?? "Failed to save changes. Please try again.")
+
+ return (
+
+ {/*
+ * The icon is always inside this same Box so its screen position never
+ * changes when the notification text appears or disappears.
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {popupTitle}
+
+
+ {popupBody}
+
+
+
+
+
+ {notifText && (
+
+ {notifText}
+
+ )}
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
new file mode 100644
index 0000000..90398ef
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -0,0 +1,351 @@
+import {
+ AlertDialog,
+ AlertDialogBody,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ Badge,
+ Box,
+ Button,
+ Divider,
+ Flex,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Popover,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ useDisclosure,
+} from "@chakra-ui/react"
+import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import { PiPlus } from "@react-icons/all-files/pi/PiPlus"
+import React, { useCallback, useEffect, useRef, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import type { TemplateRecord } from "../../storage"
+import { useEditorStore } from "../../store"
+
+const MAX_VISIBLE = 8
+
+export function TemplatesDropdown() {
+ const provider = useTemplateStorage()
+ const { isOpen, onOpen, onClose } = useDisclosure()
+ const [templates, setTemplates] = useState([])
+ const [search, setSearch] = useState("")
+ const searchRef = useRef(null)
+ const cancelRef = useRef(null)
+
+ const [pendingTemplate, setPendingTemplate] = useState(
+ null,
+ )
+
+ const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
+ useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
+
+ const fetchTemplates = useCallback(async () => {
+ if (!provider) return
+ const list = await provider.listTemplates()
+ setTemplates(list)
+ }, [provider])
+
+ useEffect(() => {
+ if (isOpen) {
+ setSearch("")
+ fetchTemplates()
+ setTimeout(() => searchRef.current?.focus(), 50)
+ }
+ }, [isOpen, fetchTemplates])
+
+ if (!provider) return null
+
+ const activeTemplate = templateId
+ ? (templates.find((t) => t.id === templateId) ?? null)
+ : null
+
+ // Show a "Current" row whenever the editor has live (non-pristine) state,
+ // regardless of whether the template has been saved to the provider yet.
+ const shouldShowCurrent = !isPristine
+
+ // Prefer server-side transformation count when available; fall back to store.
+ const currentTransformCount = activeTemplate
+ ? activeTemplate.transformations.length
+ : transformations.length
+
+ const filtered = templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
+ .slice(0, MAX_VISIBLE)
+
+ const doLoadTemplate = (record: TemplateRecord) => {
+ loadTemplate(record.transformations)
+ setTemplateName(record.name)
+ setTemplateId(record.id)
+ onClose()
+ setPendingTemplate(null)
+ }
+
+ const handleSelect = (record: TemplateRecord) => {
+ if (isPristine || syncStatus === "saved") {
+ doLoadTemplate(record)
+ } else {
+ setPendingTemplate(record)
+ onClose()
+ }
+ }
+
+ const handleNewTemplate = () => {
+ resetToNewTemplate()
+ onClose()
+ }
+
+ const handleSaveAndContinue = async () => {
+ if (!provider || !pendingTemplate) return
+ const state = useEditorStore.getState()
+ setSyncStatus("saving")
+ try {
+ await provider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save",
+ )
+ }
+ doLoadTemplate(pendingTemplate)
+ }
+
+ const handleContinueWithoutSaving = () => {
+ if (pendingTemplate) doLoadTemplate(pendingTemplate)
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ variant="filled"
+ bg="editorGray.200"
+ _focus={{ bg: "editorGray.200" }}
+ borderRadius="md"
+ fontSize="sm"
+ />
+
+ }
+ onClick={handleNewTemplate}
+ variant="ghost"
+ colorScheme="blue"
+ flexShrink={0}
+ fontWeight="normal"
+ >
+ New
+
+
+
+
+ {shouldShowCurrent && (
+
+
+
+
+ {templateName}
+
+
+ Current
+
+
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
+
+
+ )}
+
+ {filtered.length === 0 && !shouldShowCurrent ? (
+
+
+ {search ? "No templates found" : "No saved templates yet"}
+
+
+ ) : filtered.length === 0 && shouldShowCurrent ? (
+
+
+ {search
+ ? "No other templates found"
+ : "No other saved templates"}
+
+
+ ) : (
+ filtered.map((record) => (
+ handleSelect(record)}
+ >
+
+
+ {record.name}
+
+
+ {record.transformations.length} transformation
+ {record.transformations.length !== 1 ? "s" : ""}
+
+
+
+ ))
+ )}
+
+
+ {templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) && (
+ <>
+
+
+
+ View all templates
+
+
+ >
+ )}
+
+
+
+
+ setPendingTemplate(null)}
+ isCentered
+ >
+
+
+
+ Unsaved changes
+
+
+ Your current changes haven't been saved yet. What would you like
+ to do before switching to{" "}
+
+ {pendingTemplate?.name}
+
+ ?
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 9180329..94d330f 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -8,17 +8,14 @@ import {
MenuItem,
MenuList,
Spacer,
- Text,
} from "@chakra-ui/react"
-import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare"
-import { PiImagesSquare } from "@react-icons/all-files/pi/PiImagesSquare"
import { PiX } from "@react-icons/all-files/pi/PiX"
-import React, { useMemo } from "react"
-import {
- type FileElement,
- type RequiredMetadata,
- useEditorStore,
-} from "../../store"
+import React from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { type FileElement, type RequiredMetadata, useEditorStore } from "../../store"
+import { TemplateNameInput } from "./TemplateNameInput"
+import { TemplateStatus } from "./TemplateStatus"
+import { TemplatesDropdown } from "./TemplatesDropdown"
interface ExportOptionButton<
Metadata extends RequiredMetadata = RequiredMetadata,
@@ -54,15 +51,7 @@ export interface HeaderProps<
export const Header = ({ onClose, exportOptions }: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
-
- const headerText = useMemo(() => {
- if (imageList.length === 1) {
- return decodeURIComponent(
- currentImage?.split("/").pop()?.split("?")?.[0] || "",
- )
- }
- return `${imageList.length} Images`
- }, [imageList, currentImage])
+ const provider = useTemplateStorage()
return (
{
borderBottomColor="editorBattleshipGrey.100"
flexShrink={0}
>
-
- {headerText}
+ {provider ? (
+
+
+
+
+ ) : null}
+
{exportOptions
?.filter((exportOption) =>
diff --git a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
new file mode 100644
index 0000000..c591f83
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
@@ -0,0 +1,24 @@
+import React, { createContext, useContext } from "react"
+import type { TemplateStorageProvider } from "../storage"
+
+const TemplateStorageContext = createContext(
+ null,
+)
+
+export function TemplateStorageContextProvider({
+ provider,
+ children,
+}: {
+ provider: TemplateStorageProvider | null
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTemplateStorage(): TemplateStorageProvider | null {
+ return useContext(TemplateStorageContext)
+}
diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
new file mode 100644
index 0000000..3bc3620
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
@@ -0,0 +1,85 @@
+import { useEffect, useRef } from "react"
+import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { useEditorStore } from "../store"
+
+const DEBOUNCE_MS = 600
+
+/**
+ * Automatically persists the template to the storage provider whenever
+ * transformations or the template name change. Uses saveTemplate() so the
+ * record is immediately visible in listTemplates().
+ *
+ * Why transformationToEdit is NOT in the subscribed slice
+ * --------------------------------------------------------
+ * The Zustand store only holds committed transformation state.
+ * updateTransformation / addTransformation are called on "Apply", not on
+ * every keystroke — react-hook-form owns the live form state and never touches
+ * the store. So whether a config sidebar is open is irrelevant to whether the
+ * store data is ready to save. Including transformationToEdit as a guard
+ * causes exactly the bug it was meant to prevent: "Apply" without closing the
+ * form leaves transformationToEdit non-null, so the subscription callback
+ * returns early and the change is never persisted.
+ *
+ * Why templateId is NOT in the subscribed slice
+ * -----------------------------------------------
+ * setTemplateId() is called on every save success. Including it would
+ * re-trigger the subscription and cause an infinite save loop.
+ */
+export function useAutoSaveTemplate() {
+ const provider = useTemplateStorage()
+ const timerRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (!provider) return
+
+ const unsubscribe = useEditorStore.subscribe(
+ (state) => ({
+ transformations: state.transformations,
+ templateName: state.templateName,
+ isPristine: state.isPristine,
+ }),
+ (slice) => {
+ if (slice.isPristine) return
+
+ if (timerRef.current) clearTimeout(timerRef.current)
+
+ timerRef.current = setTimeout(async () => {
+ // Re-read fresh state at fire time: the slice snapshot can be up to
+ // DEBOUNCE_MS stale by the time the timer fires.
+ const state = useEditorStore.getState()
+ if (state.isPristine) return
+
+ const { setSyncStatus, setTemplateId } = state
+ setSyncStatus("saving")
+ try {
+ const saved = await provider.saveTemplate({
+ id: state.templateId ?? undefined,
+ name: state.templateName,
+ transformations: state.transformations.map(
+ ({ id: _id, ...rest }) => rest,
+ ),
+ })
+ setTemplateId(saved.id)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to auto-save template",
+ )
+ }
+ }, DEBOUNCE_MS)
+ },
+ {
+ equalityFn: (a, b) =>
+ a.transformations === b.transformations &&
+ a.templateName === b.templateName &&
+ a.isPristine === b.isPristine,
+ },
+ )
+
+ return () => {
+ unsubscribe()
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [provider])
+}
diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
new file mode 100644
index 0000000..de03ed2
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
@@ -0,0 +1,48 @@
+import { useCallback, useEffect } from "react"
+import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { useEditorStore } from "../store"
+
+export function useSaveTemplate() {
+ const provider = useTemplateStorage()
+ const { setSyncStatus, setTemplateId, setTemplateName } = useEditorStore()
+
+ const save = useCallback(async () => {
+ if (!provider) return
+
+ const state = useEditorStore.getState()
+ const { transformations, templateName, templateId } = state
+
+ setSyncStatus("saving")
+ try {
+ const saved = await provider.saveTemplate({
+ id: templateId ?? undefined,
+ name: templateName,
+ transformations: transformations.map(({ id: _id, ...rest }) => rest),
+ })
+ setTemplateId(saved.id)
+ setTemplateName(saved.name)
+ setSyncStatus("saved")
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save template",
+ )
+ }
+ }, [provider, setSyncStatus, setTemplateId, setTemplateName])
+
+ useEffect(() => {
+ if (!provider) return
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
+ e.preventDefault()
+ save()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [provider, save])
+
+ return { save }
+}
diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx
index 18ce1c9..fb34fbd 100644
--- a/packages/imagekit-editor-dev/src/index.tsx
+++ b/packages/imagekit-editor-dev/src/index.tsx
@@ -1,5 +1,11 @@
export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor"
export { ImageKitEditor } from "./ImageKitEditor"
export { DEFAULT_FOCUS_OBJECTS } from "./schema"
+export type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./storage"
+export { createLocalStorageProvider } from "./storage"
export type { FileElement, Signer, Transformation } from "./store"
export { TRANSFORMATION_STATE_VERSION } from "./store"
diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts
index 9ec8a4f..de2470f 100644
--- a/packages/imagekit-editor-dev/src/schema/index.ts
+++ b/packages/imagekit-editor-dev/src/schema/index.ts
@@ -706,11 +706,24 @@ const baseTransformationSchema: TransformationSchema[] = [
defaultTransformation: {},
schema: z
.object({
- flip: z.coerce
- .string({
- invalid_type_error: "Should be a string.",
- })
- .optional(),
+ // z.preprocess normalises legacy string values that were coerced
+ // from the array before this fix (e.g. "horizontal",
+ // "horizontal,vertical", or corrupted "h,,,o,r,i,z,n,t,a,l,...").
+ flip: z.preprocess(
+ (val) => {
+ if (Array.isArray(val)) return val
+ if (typeof val === "string" && val) {
+ return val
+ .split(",")
+ .map((s) => s.trim())
+ .filter(
+ (s) => s === "horizontal" || s === "vertical",
+ )
+ }
+ return []
+ },
+ z.array(z.enum(["horizontal", "vertical"])).optional(),
+ ),
})
.refine(
(val) => {
diff --git a/packages/imagekit-editor-dev/src/storage/index.ts b/packages/imagekit-editor-dev/src/storage/index.ts
new file mode 100644
index 0000000..25b0bd6
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/index.ts
@@ -0,0 +1,6 @@
+export { createLocalStorageProvider } from "./localStorage-provider"
+export type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./types"
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
new file mode 100644
index 0000000..da92aaf
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -0,0 +1,91 @@
+import type {
+ LocalStorageProviderOptions,
+ TemplateRecord,
+ TemplateStorageProvider,
+} from "./types"
+
+const DEFAULT_TEMPLATES_KEY = "ik-editor-templates"
+
+function generateId(): string {
+ return `template-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+export function createLocalStorageProvider(
+ options: LocalStorageProviderOptions = {},
+): TemplateStorageProvider {
+ const templatesKey = options.templatesKey ?? DEFAULT_TEMPLATES_KEY
+
+ function readTemplates(): TemplateRecord[] {
+ try {
+ const raw = localStorage.getItem(templatesKey)
+ if (!raw) return []
+ return JSON.parse(raw) as TemplateRecord[]
+ } catch {
+ return []
+ }
+ }
+
+ function writeTemplates(templates: TemplateRecord[]): void {
+ localStorage.setItem(templatesKey, JSON.stringify(templates))
+ }
+
+ return {
+ getProviderName() {
+ return "localStorage"
+ },
+
+ async listTemplates(): Promise {
+ const templates = readTemplates()
+ return [...templates].sort((a, b) => {
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
+ },
+
+ async getTemplate(id: string): Promise {
+ const templates = readTemplates()
+ return templates.find((t) => t.id === id) ?? null
+ },
+
+ async saveTemplate(
+ record: Omit & {
+ id?: string
+ },
+ ): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ const templates = readTemplates()
+ const now = Date.now()
+
+ if (record.id) {
+ const index = templates.findIndex((t) => t.id === record.id)
+ if (index !== -1) {
+ const updated: TemplateRecord = {
+ ...templates[index],
+ name: record.name,
+ transformations: record.transformations,
+ updatedAt: now,
+ }
+ templates[index] = updated
+ writeTemplates(templates)
+ return updated
+ }
+ }
+
+ const newRecord: TemplateRecord = {
+ id: record.id ?? generateId(),
+ name: record.name,
+ transformations: record.transformations,
+ updatedAt: now,
+ }
+ templates.push(newRecord)
+ writeTemplates(templates)
+ return newRecord
+ },
+
+ async deleteTemplate(id: string): Promise {
+ const templates = readTemplates().filter((t) => t.id !== id)
+ writeTemplates(templates)
+ },
+ }
+}
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
new file mode 100644
index 0000000..b625329
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -0,0 +1,21 @@
+import type { Transformation } from "../store"
+
+export interface TemplateRecord {
+ id: string
+ name: string
+ transformations: Omit[]
+ updatedAt: number
+ lastUsedAt?: number
+}
+
+export interface TemplateStorageProvider {
+ listTemplates(): Promise
+ getTemplate(id: string): Promise
+ saveTemplate(record: Omit & { id?: string }): Promise
+ deleteTemplate?(id: string): Promise
+ getProviderName(): string
+}
+
+export interface LocalStorageProviderOptions {
+ templatesKey?: string
+}
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index 2220a89..ac306d6 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -24,6 +24,8 @@ export interface Transformation {
type: "transformation"
value: IKTransformation
version?: typeof TRANSFORMATION_STATE_VERSION
+ /** Persisted visibility flag. Absent or true = visible; false = hidden. */
+ enabled?: boolean
}
export type RequiredMetadata = { requireSignedUrl: boolean }
@@ -72,6 +74,8 @@ export type FocusObjects =
| (typeof DEFAULT_FOCUS_OBJECTS)[number]
| (string & {})
+export type SyncStatus = "unsaved" | "saving" | "saved" | "error"
+
export interface EditorState<
Metadata extends RequiredMetadata = RequiredMetadata,
> {
@@ -88,6 +92,11 @@ export interface EditorState<
currentTransformKey: string
focusObjects?: ReadonlyArray
_internalState: InternalState
+ templateName: string
+ templateId: string | null
+ syncStatus: SyncStatus
+ storageError?: string
+ isPristine: boolean
}
export type EditorActions<
@@ -97,6 +106,8 @@ export type EditorActions<
imageList?: Array>
signer?: Signer
focusObjects?: ReadonlyArray
+ templateName?: string
+ templateId?: string
}) => void
destroy: () => void
setCurrentImage: (imageSrc: string | undefined) => void
@@ -123,6 +134,10 @@ export type EditorActions<
updatedTransformation: Omit,
) => void
setShowOriginal: (showOriginal: boolean) => void
+ setTemplateName: (name: string) => void
+ setTemplateId: (id: string | null) => void
+ setSyncStatus: (status: SyncStatus, error?: string) => void
+ resetToNewTemplate: () => void
_setSidebarState: (state: "none" | "type" | "config") => void
_setSelectedTransformationKey: (key: string | null) => void
@@ -184,6 +199,11 @@ const DEFAULT_STATE: EditorState = {
selectedTransformationKey: null,
transformationToEdit: null,
},
+ templateName: "Untitled Template",
+ templateId: null,
+ syncStatus: "unsaved",
+ storageError: undefined,
+ isPristine: true,
}
const useEditorStore = create()(
@@ -204,6 +224,14 @@ const useEditorStore = create()(
if (initialData?.focusObjects) {
updates.focusObjects = initialData.focusObjects
}
+ if (initialData?.templateName) {
+ updates.templateName = initialData.templateName
+ updates.isPristine = false
+ }
+ if (initialData?.templateId) {
+ updates.templateId = initialData.templateId
+ updates.isPristine = false
+ }
if (Object.keys(updates).length > 0) {
set(updates as EditorState)
}
@@ -314,7 +342,8 @@ const useEditorStore = create()(
const visibleTransformations: Record = {}
transformationsWithIds.forEach((t) => {
- visibleTransformations[t.id] = true
+ // enabled absent or true → visible; false → hidden
+ visibleTransformations[t.id] = t.enabled !== false
})
set((state) => ({
@@ -328,6 +357,7 @@ const useEditorStore = create()(
selectedTransformationKey: null,
transformationToEdit: null,
},
+ isPristine: false,
}))
},
@@ -343,24 +373,33 @@ const useEditorStore = create()(
)
if (oldIndex !== -1 && newIndex !== -1) {
- // Create a new array with the moved item
const updatedTransformations = [...state.transformations]
const [removed] = updatedTransformations.splice(oldIndex, 1)
updatedTransformations.splice(newIndex, 0, removed)
- return { transformations: updatedTransformations }
+ return { transformations: updatedTransformations, isPristine: false }
}
return { transformations: state.transformations }
})
},
toggleTransformationVisibility: (id) => {
- set((state) => ({
- visibleTransformations: {
- ...state.visibleTransformations,
- [id]: !state.visibleTransformations[id],
- },
- }))
+ set((state) => {
+ const newVisible = !state.visibleTransformations[id]
+ return {
+ visibleTransformations: {
+ ...state.visibleTransformations,
+ [id]: newVisible,
+ },
+ // Sync enabled into the transformations array so the auto-save
+ // subscription (which watches `transformations`) fires, and so the
+ // visibility state is persisted alongside the transformation data.
+ transformations: state.transformations.map((t) =>
+ t.id === id ? { ...t, enabled: newVisible } : t,
+ ),
+ isPristine: false,
+ }
+ })
},
addTransformation: (transformation, position) => {
@@ -376,6 +415,7 @@ const useEditorStore = create()(
...state.visibleTransformations,
[id]: true,
},
+ isPristine: false,
}
})
@@ -392,6 +432,7 @@ const useEditorStore = create()(
...state.visibleTransformations,
[id]: true,
},
+ isPristine: false,
}
})
@@ -403,6 +444,7 @@ const useEditorStore = create()(
transformations: state.transformations.filter(
(transformation) => transformation.id !== id,
),
+ isPristine: false,
}))
},
@@ -414,6 +456,7 @@ const useEditorStore = create()(
transformations: state.transformations.map((t) =>
t.id === id ? { ...updatedTransformation, id } : t,
),
+ isPristine: false,
}))
},
@@ -423,6 +466,38 @@ const useEditorStore = create()(
}))
},
+ setTemplateName: (name) => {
+ set((state) => ({
+ templateName: name,
+ isPristine: state.templateName === name ? state.isPristine : false,
+ }))
+ },
+
+ setTemplateId: (id) => {
+ set({ templateId: id })
+ },
+
+ setSyncStatus: (status, error?) => {
+ set({ syncStatus: status, storageError: error })
+ },
+
+ resetToNewTemplate: () => {
+ set({
+ transformations: [],
+ visibleTransformations: {},
+ templateName: "Untitled Template",
+ templateId: null,
+ syncStatus: "unsaved",
+ storageError: undefined,
+ isPristine: true,
+ _internalState: {
+ sidebarState: "none",
+ selectedTransformationKey: null,
+ transformationToEdit: null,
+ },
+ })
+ },
+
_setSidebarState: (sidebarState) => {
set((state) => ({
_internalState: { ...state._internalState, sidebarState },
From ff641630d7bffb7942545d8a1accd7fd7cd95940 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 15:17:02 +0530
Subject: [PATCH 10/64] feat: working commit for view all templates component
---
package.json | 2 +
.../components/common/FilterChipsField.tsx | 121 ++++
.../common/MultiSelectListField.tsx | 194 ++++++
.../src/components/editor/layout.tsx | 52 +-
.../components/header/TemplatesDropdown.tsx | 37 +-
.../src/components/header/index.tsx | 5 +-
.../templates/TemplatesLibraryView.tsx | 560 ++++++++++++++++++
.../imagekit-editor-dev/src/storage/index.ts | 2 +
.../src/storage/localStorage-provider.ts | 45 +-
.../imagekit-editor-dev/src/storage/types.ts | 26 +-
packages/imagekit-editor-dev/src/theme.ts | 18 +
yarn.lock | 16 +
12 files changed, 1040 insertions(+), 38 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
diff --git a/package.json b/package.json
index 5046f3a..cd06372 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
+ "@types/human-date": "^1",
"@types/jsdom": "^28",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
@@ -44,6 +45,7 @@
]
},
"dependencies": {
+ "human-date": "^1.4.0",
"react-select": "^5.2.1"
}
}
diff --git a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
new file mode 100644
index 0000000..58ba8a9
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
@@ -0,0 +1,121 @@
+import {
+ type As,
+ Box,
+ Flex,
+ HStack,
+ Icon,
+ Text,
+ useColorModeValue,
+} from "@chakra-ui/react"
+import type * as React from "react"
+
+type FilterChipsOption = {
+ label: string
+ value: string
+ icon?: React.ReactNode
+}
+
+type FilterChipsFieldProps = {
+ id?: string
+ value?: string[]
+ options: FilterChipsOption[]
+ onChange: (values: string[]) => void
+ maxSelections?: number
+}
+
+const toggleValue = (
+ current: string[] = [],
+ v: string,
+ max?: number,
+): string[] => {
+ const currentArray = Array.isArray(current) ? current : []
+ const set = new Set(currentArray)
+ if (set.has(v)) {
+ set.delete(v)
+ return Array.from(set)
+ }
+ if (typeof max === "number" && currentArray.length >= max) return currentArray
+ set.add(v)
+ return Array.from(set)
+}
+
+export const FilterChipsField: React.FC = ({
+ id,
+ value = [],
+ options,
+ onChange,
+ maxSelections,
+}) => {
+ const selectedBg = useColorModeValue("blue.50", "blue.900")
+ const selectedBorder = useColorModeValue("blue.400", "blue.300")
+ const hoverBg = useColorModeValue("gray.50", "whiteAlpha.100")
+ const safeValue = Array.isArray(value) ? value : []
+ const isMaxed =
+ typeof maxSelections === "number" && safeValue.length >= maxSelections
+
+ const handleKeyDown = (
+ e: React.KeyboardEvent,
+ v: string,
+ disabled?: boolean,
+ ) => {
+ if (disabled) return
+ if (e.key === " " || e.key === "Enter") {
+ e.preventDefault()
+ onChange(toggleValue(safeValue, v, maxSelections))
+ }
+ }
+
+ return (
+
+ {options.map((opt) => {
+ const isChecked = safeValue.includes(opt.value)
+ const disabled = opt.isDisabled || (!isChecked && isMaxed)
+ return (
+ {
+ if (disabled) return
+ onChange(toggleValue(safeValue, opt.value, maxSelections))
+ }}
+ onKeyDown={(e) => handleKeyDown(e, opt.value, disabled)}
+ cursor={disabled ? "not-allowed" : "pointer"}
+ opacity={disabled ? 0.5 : 1}
+ borderWidth="1px"
+ borderRadius="md"
+ p="2"
+ transition="all 0.12s ease-in-out"
+ borderColor={isChecked ? selectedBorder : "gray.200"}
+ bg={isChecked ? selectedBg : "transparent"}
+ _hover={{
+ bg: disabled ? undefined : isChecked ? selectedBg : hoverBg,
+ }}
+ _focusVisible={{
+ boxShadow: "0 0 0 2px var(--chakra-colors-blue-400)",
+ outline: "none",
+ }}
+ >
+
+ {opt.icon ? : null}
+
+ {opt.label}
+
+
+
+ )
+ })}
+
+ )
+}
+
+export default FilterChipsField
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
new file mode 100644
index 0000000..f25b533
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -0,0 +1,194 @@
+import {
+ Avatar,
+ Box,
+ Checkbox,
+ Divider,
+ Flex,
+ HStack,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Text,
+} from "@chakra-ui/react"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import type * as React from "react"
+import { useMemo, useState } from "react"
+
+export type MultiSelectListOption = {
+ label: string
+ value: string
+ avatar?: string
+ email?: string
+ isDisabled?: boolean
+}
+
+type MultiSelectListFieldProps = {
+ id?: string
+ value?: string[]
+ options: MultiSelectListOption[]
+ onChange: (values: string[]) => void
+ maxHeight?: string
+ isSearchable?: boolean
+ searchPlaceholder?: string
+ selectedFirst?: boolean
+ showSelectedSeparator?: boolean
+}
+
+export const MultiSelectListField: React.FC = ({
+ id,
+ value = [],
+ options,
+ onChange,
+ maxHeight = "300px",
+ isSearchable = false,
+ searchPlaceholder = "Search...",
+ selectedFirst = false,
+ showSelectedSeparator = false,
+}) => {
+ const safeValue = Array.isArray(value) ? value : []
+ const [query, setQuery] = useState("")
+
+ const toggleValue = (v: string) => {
+ const set = new Set(safeValue)
+ if (set.has(v)) {
+ set.delete(v)
+ } else {
+ set.add(v)
+ }
+ onChange(Array.from(set))
+ }
+
+ const { selected, other } = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const filtered =
+ q.length === 0
+ ? options
+ : options.filter((o) => {
+ const haystack = `${o.label} ${o.email ?? ""}`.toLowerCase()
+ return haystack.includes(q)
+ })
+
+ if (!selectedFirst) return { selected: filtered, other: [] }
+
+ const selectedOptions: MultiSelectListOption[] = []
+ const otherOptions: MultiSelectListOption[] = []
+ const selectedSet = new Set(safeValue)
+ for (const opt of filtered) {
+ ;(selectedSet.has(opt.value) ? selectedOptions : otherOptions).push(opt)
+ }
+ return { selected: selectedOptions, other: otherOptions }
+ }, [options, query, safeValue, selectedFirst])
+
+ const shouldRenderSeparator =
+ selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0
+
+ const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => {
+ const isChecked = safeValue.includes(opt.value)
+ const disabled = opt.isDisabled
+
+ return (
+ {
+ if (!disabled) toggleValue(opt.value)
+ }}
+ _hover={{
+ bg: disabled ? undefined : "gray.50",
+ }}
+ borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"}
+ borderBottomColor="gray.100"
+ transition="background-color 0.12s ease-in-out"
+ >
+ {
+ if (!disabled) toggleValue(opt.value)
+ }}
+ pointerEvents="none"
+ flexShrink={0}
+ />
+
+
+
+
+
+ {opt.label}
+
+ {opt.email && (
+
+ {opt.email}
+
+ )}
+
+
+ )
+ }
+
+ const renderedCount = selectedFirst ? selected.length + other.length : selected.length
+
+ return (
+
+ {isSearchable ? (
+
+
+
+
+
+ setQuery(e.target.value)}
+ bg="gray.50"
+ borderColor="gray.200"
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
+ />
+
+
+ ) : null}
+
+
+ {selectedFirst ? (
+ <>
+ {selected.map((opt, idx) => renderOption(opt, idx, selected.length))}
+ {shouldRenderSeparator ? : null}
+ {other.map((opt, idx) => renderOption(opt, idx, other.length))}
+ >
+ ) : (
+ selected.map((opt, idx) => renderOption(opt, idx, selected.length))
+ )}
+
+ {renderedCount === 0 && (
+
+ {query.trim() ? "No matches found" : "No items available"}
+
+ )}
+
+
+ )
+}
+
+export default MultiSelectListField
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index f0a0fec..693c190 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -4,6 +4,7 @@ import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
import { Sidebar } from "../sidebar"
+import { TemplatesLibraryView } from "../templates/TemplatesLibraryView"
import { ActionBar } from "./ActionBar"
import { GridView } from "./GridView"
import { ListView } from "./ListView"
@@ -17,33 +18,44 @@ interface Props {
export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
+ const [layoutMode, setLayoutMode] = useState<"editor" | "templates">(
+ "templates",
+ )
useAutoSaveTemplate()
useSaveTemplate()
return (
<>
-
-
-
-
-
- {viewMode === "list" && }
- {viewMode === "grid" && (
-
- )}
+ setLayoutMode("templates")}
+ />
+ {layoutMode === "templates" ? (
+ setLayoutMode("editor")} />
+ ) : (
+
+
+
+
+ {viewMode === "list" && }
+ {viewMode === "grid" && (
+
+ )}
+
-
+ )}
>
)
}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 90398ef..e94cbe5 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -31,7 +31,11 @@ import { useEditorStore } from "../../store"
const MAX_VISIBLE = 8
-export function TemplatesDropdown() {
+interface TemplatesDropdownProps {
+ onViewAllTemplates?: () => void
+}
+
+export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps) {
const provider = useTemplateStorage()
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
@@ -165,7 +169,9 @@ export function TemplatesDropdown() {
shadow="lg"
p="0"
overflow="hidden"
- _focus={{ boxShadow: "lg" }}
+ borderWidth="0"
+ outline="none"
+ _focus={{ boxShadow: "lg", outline: "none", borderColor: "transparent" }}
>
- {templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) && (
+ {/* Always show "View all templates" when callback is provided, or when there are more templates than visible */}
+ {onViewAllTemplates ? (
<>
- {
+ onClose()
+ // Defer to next tick to allow popover to close cleanly
+ setTimeout(() => onViewAllTemplates?.(), 0)
+ }}
>
View all templates
+
+
+ >
+ ) : templates.length > MAX_VISIBLE + (shouldShowCurrent ? 1 : 0) ? (
+ <>
+
+
+
+ {templates.length} templates total
>
- )}
+ ) : null}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 94d330f..279ca57 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -47,9 +47,10 @@ export interface HeaderProps<
exportOptions?: Array<
ExportOptionButton | ExportOptionMenu
>
+ onViewAllTemplates?: () => void
}
-export const Header = ({ onClose, exportOptions }: HeaderProps) => {
+export const Header = ({ onClose, exportOptions, onViewAllTemplates }: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const provider = useTemplateStorage()
@@ -69,7 +70,7 @@ export const Header = ({ onClose, exportOptions }: HeaderProps) => {
{provider ? (
-
+
) : null}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
new file mode 100644
index 0000000..7758469
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -0,0 +1,560 @@
+import {
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ Divider,
+ Flex,
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ Popover,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Spinner,
+ Text,
+} from "@chakra-ui/react"
+import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
+import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
+import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
+import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
+import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import humanDate from "human-date"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useDebounce } from "../../hooks/useDebounce"
+import type { TemplateRecord } from "../../storage"
+import { useEditorStore } from "../../store"
+import FilterChipsField from "../common/FilterChipsField"
+import MultiSelectListField from "../common/MultiSelectListField"
+
+interface Props {
+ onBack(): void
+}
+
+function formatRelativeTime(ts: number): string {
+ return humanDate.relativeTime(new Date(ts))
+}
+
+export function TemplatesLibraryView({ onBack }: Props) {
+ const provider = useTemplateStorage()
+ const [templates, setTemplates] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [searchInput, setSearchInput] = useState("")
+ const search = useDebounce(searchInput, 200)
+ const [visibilityFilter, setVisibilityFilter] = useState([])
+ const [creatorFilter, setCreatorFilter] = useState([])
+
+ const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const isPristine = useEditorStore((s) => s.isPristine)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
+
+ const fetchTemplates = useCallback(async () => {
+ if (!provider) return
+ setLoading(true)
+ try {
+ const list = await provider.listTemplates()
+ setTemplates(list)
+ } finally {
+ setLoading(false)
+ }
+ }, [provider])
+
+ useEffect(() => {
+ fetchTemplates()
+ }, [fetchTemplates])
+
+ const shouldShowCurrent = !isPristine
+
+ const activeTemplate = templateId
+ ? (templates.find((t) => t.id === templateId) ?? null)
+ : null
+
+ const currentTransformCount = activeTemplate
+ ? activeTemplate.transformations.length
+ : transformations.length
+
+ const uniqueCreators = useMemo(() => {
+ const seen = new Map()
+ for (const t of templates) {
+ if (!seen.has(t.createdBy.userId)) {
+ seen.set(t.createdBy.userId, {
+ name: t.createdBy.name || t.createdBy.email,
+ email: t.createdBy.email,
+ })
+ }
+ }
+ return Array.from(seen.entries()).map(([userId, { name, email }]) => ({
+ userId,
+ name,
+ email,
+ }))
+ }, [templates])
+
+ const filtered = useMemo(() => {
+ return templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) =>
+ search
+ ? t.name.toLowerCase().includes(search.toLowerCase()) ||
+ t.createdBy.name.toLowerCase().includes(search.toLowerCase()) ||
+ t.createdBy.email.toLowerCase().includes(search.toLowerCase())
+ : true,
+ )
+ .filter((t) => {
+ if (visibilityFilter.length === 0) return true
+ if (visibilityFilter.includes("private")) return t.isPrivate
+ if (visibilityFilter.includes("shared")) return !t.isPrivate
+ return true
+ })
+ .filter((t) =>
+ creatorFilter.length > 0
+ ? creatorFilter.includes(t.createdBy.userId)
+ : true,
+ )
+ }, [templates, templateId, search, visibilityFilter, creatorFilter])
+
+ const handleSelect = (record: TemplateRecord) => {
+ if (isPristine || syncStatus === "saved") {
+ loadTemplate(record.transformations)
+ setTemplateName(record.name)
+ setTemplateId(record.id)
+ onBack()
+ }
+ }
+
+ const handleTogglePin = async (record: TemplateRecord) => {
+ if (!provider) return
+
+ // For the local storage provider we only have a single logical user.
+ const currentUserId = "local"
+ const isPinned = record.pinnedBy.includes(currentUserId)
+ const nextPinnedBy = isPinned
+ ? record.pinnedBy.filter((id) => id !== currentUserId)
+ : [...record.pinnedBy, currentUserId]
+
+ try {
+ const updated = await provider.saveTemplate({
+ id: record.id,
+ name: record.name,
+ transformations: record.transformations,
+ clientNumber: record.clientNumber,
+ isPrivate: record.isPrivate,
+ pinnedBy: nextPinnedBy,
+ createdBy: record.createdBy,
+ updatedBy: record.updatedBy,
+ createdAt: record.createdAt,
+ })
+
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === updated.id ? updated : t)),
+ )
+ } catch {
+ // Silently ignore pin failures in this view
+ }
+ }
+
+ return (
+
+ {/* Static top section: back button, title, filters */}
+
+
+ {/* Page header */}
+ }
+ color="editorBattleshipGrey.500"
+ _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
+ px="0"
+ >
+ Go back
+
+
+
+
+ All templates
+
+
+ Browse and load templates shared with you or created by your team.
+
+
+
+ {/* Controls bar */}
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ bg="white"
+ borderColor="gray.200"
+ borderRadius="md"
+ px="2"
+ py="2"
+ fontSize="sm"
+ fontWeight="400"
+ _placeholder={{ fontWeight: "400" }}
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
+ />
+
+
+
+
+
+
+
+
+ 0 ? 1 : 0.5}
+ >
+ Created by
+
+ {creatorFilter.length > 0 && (
+
+ {creatorFilter.length}
+
+ )}
+
+
+
+
+
+
+ ({
+ label: name,
+ value: userId,
+ email: email || undefined,
+ }))}
+ value={creatorFilter}
+ onChange={setCreatorFilter}
+ isSearchable
+ selectedFirst
+ showSelectedSeparator
+ />
+
+
+
+
+
+
+
+
+
+ {/* Scrollable table area */}
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+ {/* Table header */}
+
+
+ Name
+
+
+ Created by
+
+
+ Visibility
+
+
+ Last updated
+
+
+
+
+ {/* Current row */}
+ {shouldShowCurrent && (
+
+
+
+
+ {templateName}
+
+
+ Current
+
+
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
+
+
+ )}
+
+ {/* Filtered templates */}
+ {filtered.length === 0 ? (
+
+
+ {search ||
+ visibilityFilter.length > 0 ||
+ creatorFilter.length > 0
+ ? "No templates match your filters"
+ : shouldShowCurrent
+ ? "No other saved templates"
+ : "No saved templates yet"}
+
+
+ ) : (
+ filtered.map((record) => (
+
+ ))
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+interface TemplateRowProps {
+ record: TemplateRecord
+ onSelect(record: TemplateRecord): void
+ onTogglePin(record: TemplateRecord): void
+}
+
+function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
+ return (
+ onSelect(record)}
+ >
+ {/* Name + transform count */}
+
+
+ {record.name}
+
+
+ {record.transformations.length} transformation
+ {record.transformations.length !== 1 ? "s" : ""}
+
+
+
+ {/* Creator */}
+
+
+
+
+ {record.createdBy.name || record.createdBy.email}
+
+
+ {record.createdBy.email}
+
+
+
+
+ {/* Visibility */}
+
+
+
+
+ {record.isPrivate ? "Only to me" : "Shared with everyone"}
+
+
+
+
+ {/* Last updated */}
+
+
+ {formatRelativeTime(record.updatedAt)}
+
+
+
+ {/* Pin */}
+
+ {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+
+
+
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/storage/index.ts b/packages/imagekit-editor-dev/src/storage/index.ts
index 25b0bd6..fbfd074 100644
--- a/packages/imagekit-editor-dev/src/storage/index.ts
+++ b/packages/imagekit-editor-dev/src/storage/index.ts
@@ -1,6 +1,8 @@
export { createLocalStorageProvider } from "./localStorage-provider"
export type {
LocalStorageProviderOptions,
+ SaveTemplateInput,
+ TemplateCreator,
TemplateRecord,
TemplateStorageProvider,
} from "./types"
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index da92aaf..f39516f 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -1,15 +1,39 @@
import type {
LocalStorageProviderOptions,
+ SaveTemplateInput,
+ TemplateCreator,
TemplateRecord,
TemplateStorageProvider,
} from "./types"
const DEFAULT_TEMPLATES_KEY = "ik-editor-templates"
+const LOCAL_USER: TemplateCreator = { userId: "local", name: "You", email: "" }
+
function generateId(): string {
return `template-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
+function normalizeRecord(raw: Record): TemplateRecord {
+ const now = Date.now()
+ const updatedAt = (raw.updatedAt as number) || now
+ return {
+ id: (raw.id as string) || generateId(),
+ clientNumber: (raw.clientNumber as string) || "local",
+ isPrivate:
+ raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
+ name: (raw.name as string) || "",
+ transformations:
+ (raw.transformations as TemplateRecord["transformations"]) || [],
+ pinnedBy: (raw.pinnedBy as string[]) || [],
+ createdBy: (raw.createdBy as TemplateCreator) || LOCAL_USER,
+ updatedBy: (raw.updatedBy as TemplateCreator) || LOCAL_USER,
+ createdAt: (raw.createdAt as number) || updatedAt,
+ updatedAt,
+ lastUsedAt: raw.lastUsedAt as number | undefined,
+ }
+}
+
export function createLocalStorageProvider(
options: LocalStorageProviderOptions = {},
): TemplateStorageProvider {
@@ -19,7 +43,8 @@ export function createLocalStorageProvider(
try {
const raw = localStorage.getItem(templatesKey)
if (!raw) return []
- return JSON.parse(raw) as TemplateRecord[]
+ const parsed = JSON.parse(raw) as Record[]
+ return parsed.map(normalizeRecord)
} catch {
return []
}
@@ -48,11 +73,7 @@ export function createLocalStorageProvider(
return templates.find((t) => t.id === id) ?? null
},
- async saveTemplate(
- record: Omit & {
- id?: string
- },
- ): Promise {
+ async saveTemplate(record: SaveTemplateInput): Promise {
await new Promise((resolve) => setTimeout(resolve, 1500))
const templates = readTemplates()
const now = Date.now()
@@ -60,11 +81,15 @@ export function createLocalStorageProvider(
if (record.id) {
const index = templates.findIndex((t) => t.id === record.id)
if (index !== -1) {
+ const existing = templates[index]
const updated: TemplateRecord = {
- ...templates[index],
+ ...existing,
name: record.name,
transformations: record.transformations,
+ isPrivate: record.isPrivate ?? existing.isPrivate,
+ pinnedBy: record.pinnedBy ?? existing.pinnedBy,
updatedAt: now,
+ updatedBy: record.updatedBy ?? LOCAL_USER,
}
templates[index] = updated
writeTemplates(templates)
@@ -74,8 +99,14 @@ export function createLocalStorageProvider(
const newRecord: TemplateRecord = {
id: record.id ?? generateId(),
+ clientNumber: record.clientNumber ?? "local",
+ isPrivate: record.isPrivate ?? true,
name: record.name,
transformations: record.transformations,
+ pinnedBy: record.pinnedBy ?? [],
+ createdBy: record.createdBy ?? LOCAL_USER,
+ updatedBy: record.updatedBy ?? record.createdBy ?? LOCAL_USER,
+ createdAt: record.createdAt ?? now,
updatedAt: now,
}
templates.push(newRecord)
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
index b625329..efe8b44 100644
--- a/packages/imagekit-editor-dev/src/storage/types.ts
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -1,17 +1,41 @@
import type { Transformation } from "../store"
+export interface TemplateCreator {
+ userId: string
+ name: string
+ email: string
+}
+
export interface TemplateRecord {
id: string
+ clientNumber: string
+ isPrivate: boolean
name: string
transformations: Omit[]
+ pinnedBy: string[]
+ createdBy: TemplateCreator
+ updatedBy: TemplateCreator
+ createdAt: number
updatedAt: number
lastUsedAt?: number
}
+export type SaveTemplateInput = {
+ id?: string
+ name: string
+ transformations: Omit[]
+ clientNumber?: string
+ isPrivate?: boolean
+ pinnedBy?: string[]
+ createdBy?: TemplateCreator
+ updatedBy?: TemplateCreator
+ createdAt?: number
+}
+
export interface TemplateStorageProvider {
listTemplates(): Promise
getTemplate(id: string): Promise
- saveTemplate(record: Omit & { id?: string }): Promise
+ saveTemplate(record: SaveTemplateInput): Promise
deleteTemplate?(id: string): Promise
getProviderName(): string
}
diff --git a/packages/imagekit-editor-dev/src/theme.ts b/packages/imagekit-editor-dev/src/theme.ts
index b5f4e2e..3f6213a 100644
--- a/packages/imagekit-editor-dev/src/theme.ts
+++ b/packages/imagekit-editor-dev/src/theme.ts
@@ -1,4 +1,22 @@
export const themeOverrides = {
+ styles: {
+ global: {
+ "#ik-editor *": {
+ scrollbarWidth: "thin",
+ },
+ "#ik-editor *::-webkit-scrollbar": {
+ width: "6px",
+ height: "6px",
+ },
+ "#ik-editor *::-webkit-scrollbar-thumb": {
+ background: "rgba(160, 174, 192, 0.8)",
+ borderRadius: "999px",
+ },
+ "#ik-editor *::-webkit-scrollbar-track": {
+ background: "transparent",
+ },
+ },
+ },
colors: {
editorBattleshipGrey: {
"50": "#f9f6fd",
diff --git a/yarn.lock b/yarn.lock
index ee5faf0..8dc8d5c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2771,6 +2771,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/human-date@npm:^1":
+ version: 1.4.5
+ resolution: "@types/human-date@npm:1.4.5"
+ checksum: 10c0/e3a72ceaa539e96673a0f562f21a3bde72acdcd8128ef465d385802afa2f14e8548088c6cac56a3b074a9c2add83356de5df74ec1eb49f12c4ce3953acb2bfac
+ languageName: node
+ linkType: hard
+
"@types/jsdom@npm:^28":
version: 28.0.0
resolution: "@types/jsdom@npm:28.0.0"
@@ -4399,6 +4406,13 @@ __metadata:
languageName: node
linkType: hard
+"human-date@npm:^1.4.0":
+ version: 1.4.0
+ resolution: "human-date@npm:1.4.0"
+ checksum: 10c0/4548555e36f5f6b7759a23ec7b7b4882d1b14614a653c5171235828e0b4e4ce3360da5f901b3383413b488711782c8575b8d74d7234c12e0871c7c73fe7f203f
+ languageName: node
+ linkType: hard
+
"husky@npm:^9.1.7":
version: 9.1.7
resolution: "husky@npm:9.1.7"
@@ -4468,8 +4482,10 @@ __metadata:
resolution: "imagekit-editor@workspace:."
dependencies:
"@biomejs/biome": "npm:2.1.1"
+ "@types/human-date": "npm:^1"
"@types/jsdom": "npm:^28"
"@vitest/coverage-v8": "npm:^4.0.18"
+ human-date: "npm:^1.4.0"
husky: "npm:^9.1.7"
jsdom: "npm:^28.1.0"
lint-staged: "npm:^16.1.2"
From 96b912ee06a2f517bc9a85f025c1a6388aa983fd Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 15:47:15 +0530
Subject: [PATCH 11/64] feat: brought the view templates component into a modal
overlay
---
.../common/MultiSelectListField.tsx | 17 +--
.../src/components/editor/layout.tsx | 108 +++++++++++++-----
.../templates/TemplatesLibraryView.tsx | 27 +----
3 files changed, 97 insertions(+), 55 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index f25b533..1497f4b 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -142,11 +142,10 @@ export const MultiSelectListField: React.FC = ({
{isSearchable ? (
@@ -158,12 +157,14 @@ export const MultiSelectListField: React.FC = ({
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
- bg="gray.50"
- borderColor="gray.200"
- _hover={{ borderColor: "gray.300" }}
+ variant="unstyled"
+ pl="8"
+ bg="transparent"
+ borderColor="transparent"
+ _hover={{ borderColor: "transparent" }}
_focus={{
- borderColor: "blue.500",
- boxShadow: "0 0 0 1px #3182ce",
+ borderColor: "transparent",
+ boxShadow: "none",
}}
/>
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index 693c190..4150f82 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,6 @@
-import { Flex } from "@chakra-ui/react"
-import { useState } from "react"
+import { Box, Flex, IconButton } from "@chakra-ui/react"
+import { PiX } from "@react-icons/all-files/pi/PiX"
+import { useEffect, useState } from "react"
import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { Header, type HeaderProps } from "../header"
@@ -18,9 +19,22 @@ interface Props {
export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
const [gridImageSize, setGridImageSize] = useState(300)
- const [layoutMode, setLayoutMode] = useState<"editor" | "templates">(
- "templates",
- )
+ const [isTemplatesOpen, setIsTemplatesOpen] = useState(false)
+
+ // Close templates modal on Escape while it's open
+ useEffect(() => {
+ if (!isTemplatesOpen) return
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation()
+ setIsTemplatesOpen(false)
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [isTemplatesOpen])
useAutoSaveTemplate()
useSaveTemplate()
@@ -30,32 +44,74 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
setLayoutMode("templates")}
+ onViewAllTemplates={() => setIsTemplatesOpen(true)}
/>
- {layoutMode === "templates" ? (
- setLayoutMode("editor")} />
- ) : (
-
-
-
+
+
+
+ {viewMode === "list" && }
+ {viewMode === "grid" && (
+
+ )}
+
+
+ {isTemplatesOpen ? (
+
+
- }
+ size="sm"
+ variant="ghost"
+ position="absolute"
+ top="3"
+ right="3"
+ zIndex={1}
+ onClick={() => setIsTemplatesOpen(false)}
/>
- {viewMode === "list" && }
- {viewMode === "grid" && (
-
- )}
-
-
- )}
+
+ setIsTemplatesOpen(false)} />
+
+
+
+ ) : null}
>
)
}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 7758469..01f85b4 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -16,7 +16,6 @@ import {
Spinner,
Text,
} from "@chakra-ui/react"
-import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
@@ -33,14 +32,14 @@ import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
interface Props {
- onBack(): void
+ onClose(): void
}
function formatRelativeTime(ts: number): string {
return humanDate.relativeTime(new Date(ts))
}
-export function TemplatesLibraryView({ onBack }: Props) {
+export function TemplatesLibraryView({ onClose }: Props) {
const provider = useTemplateStorage()
const [templates, setTemplates] = useState([])
const [loading, setLoading] = useState(true)
@@ -126,7 +125,7 @@ export function TemplatesLibraryView({ onBack }: Props) {
loadTemplate(record.transformations)
setTemplateName(record.name)
setTemplateId(record.id)
- onBack()
+ onClose()
}
}
@@ -170,29 +169,15 @@ export function TemplatesLibraryView({ onBack }: Props) {
background="white"
overflowY="hidden"
>
- {/* Static top section: back button, title, filters */}
+ {/* Static top section: title, filters */}
- {/* Page header */}
- }
- color="editorBattleshipGrey.500"
- _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
- px="0"
- >
- Go back
-
-
Date: Tue, 17 Mar 2026 16:16:01 +0530
Subject: [PATCH 12/64] feat: ui fixes for created by filter
---
.../common/MultiSelectListField.tsx | 56 ++++++++++++-------
.../templates/TemplatesLibraryView.tsx | 9 ++-
2 files changed, 43 insertions(+), 22 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index 1497f4b..e7e4c5e 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -81,9 +81,16 @@ export const MultiSelectListField: React.FC = ({
}, [options, query, safeValue, selectedFirst])
const shouldRenderSeparator =
- selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0
-
- const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => {
+ selectedFirst &&
+ showSelectedSeparator &&
+ selected.length > 0 &&
+ other.length > 0
+
+ const renderOption = (
+ opt: MultiSelectListOption,
+ idx: number,
+ arrLen: number,
+ ) => {
const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled
@@ -92,7 +99,8 @@ export const MultiSelectListField: React.FC = ({
key={opt.value}
px="3"
py="2.5"
- spacing="3"
+ spacing="2"
+ alignItems="center"
cursor={disabled ? "not-allowed" : "pointer"}
opacity={disabled ? 0.5 : 1}
onClick={() => {
@@ -104,6 +112,7 @@ export const MultiSelectListField: React.FC = ({
borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"}
borderBottomColor="gray.100"
transition="background-color 0.12s ease-in-out"
+ margin="2"
>
= ({
}}
pointerEvents="none"
flexShrink={0}
+ borderColor="gray.300"
+ bg="white"
+ mr="2"
/>
-
+
@@ -136,7 +143,9 @@ export const MultiSelectListField: React.FC = ({
)
}
- const renderedCount = selectedFirst ? selected.length + other.length : selected.length
+ const renderedCount = selectedFirst
+ ? selected.length + other.length
+ : selected.length
return (
= ({
bg="transparent"
>
{isSearchable ? (
-
-
-
-
-
+
+
+
setQuery(e.target.value)}
variant="unstyled"
- pl="8"
bg="transparent"
borderColor="transparent"
_hover={{ borderColor: "transparent" }}
@@ -167,14 +179,16 @@ export const MultiSelectListField: React.FC = ({
boxShadow: "none",
}}
/>
-
+
) : null}
{selectedFirst ? (
<>
- {selected.map((opt, idx) => renderOption(opt, idx, selected.length))}
+ {selected.map((opt, idx) =>
+ renderOption(opt, idx, selected.length),
+ )}
{shouldRenderSeparator ? : null}
{other.map((opt, idx) => renderOption(opt, idx, other.length))}
>
@@ -184,7 +198,9 @@ export const MultiSelectListField: React.FC = ({
{renderedCount === 0 && (
- {query.trim() ? "No matches found" : "No items available"}
+
+ {query.trim() ? "No matches found" : "No items available"}
+
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 01f85b4..6f2759e 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -286,7 +286,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
p="0"
outline="none"
boxShadow="md"
- _focus={{ outline: "none", boxShadow: "md" }}
+ borderWidth="0"
+ _focus={{
+ outline: "none",
+ boxShadow: "md",
+ borderColor: "transparent",
+ }}
>
-
+
{opt.email && (
- {opt.email}
+ ({opt.email})
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 6f2759e..f21a506 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -200,7 +200,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
w="100%"
flexWrap="wrap"
>
-
+
@@ -211,8 +211,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
bg="white"
borderColor="gray.200"
borderRadius="md"
- px="2"
- py="2"
+ px="3"
fontSize="sm"
fontWeight="400"
_placeholder={{ fontWeight: "400" }}
@@ -472,7 +471,12 @@ function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
>
{/* Name + transform count */}
-
+
{record.name}
From 8c6480e955e504bdbdfe50e9bcf57939b4f1d407 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:06:33 +0530
Subject: [PATCH 14/64] feat: template library working commit
---
.../templates/TemplatesLibraryView.tsx | 161 +++++++++++++++---
1 file changed, 139 insertions(+), 22 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index f21a506..71be793 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -9,6 +9,10 @@ import {
Input,
InputGroup,
InputLeftElement,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
Popover,
PopoverBody,
PopoverContent,
@@ -16,12 +20,14 @@ import {
Spinner,
Text,
} from "@chakra-ui/react"
+import { BsThreeDots } from "@react-icons/all-files/bs/BsThreeDots"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
import humanDate from "human-date"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
@@ -440,6 +446,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
record={record}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
+ onDelete={async (r) => {
+ if (!provider) return
+ if (!provider.deleteTemplate) return
+ await provider.deleteTemplate(r.id)
+ setTemplates((prev) => prev.filter((t) => t.id !== r.id))
+ }}
/>
))
)}
@@ -455,9 +467,16 @@ interface TemplateRowProps {
record: TemplateRecord
onSelect(record: TemplateRecord): void
onTogglePin(record: TemplateRecord): void
+ onDelete(record: TemplateRecord): void
}
-function TemplateRow({ record, onSelect, onTogglePin }: TemplateRowProps) {
+function TemplateRow({
+ record,
+ onSelect,
+ onTogglePin,
+ onDelete,
+}: TemplateRowProps) {
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
onSelect(record)}
>
+ {/* Pin */}
+
+ {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+
+
+
+
{/* Name + transform count */}
-
+
- {/* Pin */}
-
- {
- e.stopPropagation()
- onTogglePin(record)
- }}
+ {/* Row actions menu + delete confirmation popup */}
+ setShowDeleteConfirm(false)}
+ placement="bottom-end"
+ closeOnBlur
+ >
+
+ e.stopPropagation()}
+ >
+
+
+
+ e.stopPropagation()}
>
-
-
-
+
+
+ Are you sure you want to delete this template? This action is
+ irreversible.
+
+
+
+ }
+ onClick={() => {
+ setShowDeleteConfirm(false)
+ onDelete(record)
+ }}
+ >
+ Delete
+
+
+
+
+
)
}
From ce5575aba40ef56194dcdadf92752111a2e0e5c5 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:38:56 +0530
Subject: [PATCH 15/64] feat: ui fixes
---
.../templates/TemplatesLibraryView.tsx | 170 +++++++++++-------
1 file changed, 110 insertions(+), 60 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 71be793..ae792fb 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -42,7 +42,13 @@ interface Props {
}
function formatRelativeTime(ts: number): string {
- return humanDate.relativeTime(new Date(ts))
+ const now = Date.now()
+ // If the timestamp is within 10 seconds of now, show "Just now"
+ if (Math.abs(now - ts) < 10 * 1000) {
+ return "Just now"
+ }
+ const tsDate = new Date(ts)
+ return humanDate.relativeTime(tsDate)
}
export function TemplatesLibraryView({ onClose }: Props) {
@@ -53,6 +59,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
const search = useDebounce(searchInput, 200)
const [visibilityFilter, setVisibilityFilter] = useState([])
const [creatorFilter, setCreatorFilter] = useState([])
+ const [pinningId, setPinningId] = useState(null)
const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
@@ -104,7 +111,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
}, [templates])
const filtered = useMemo(() => {
- return templates
+ const base = templates
.filter((t) => t.id !== templateId)
.filter((t) =>
search
@@ -124,6 +131,20 @@ export function TemplatesLibraryView({ onClose }: Props) {
? creatorFilter.includes(t.createdBy.userId)
: true,
)
+
+ // Sort so that pinned templates (for the local user) come first,
+ // then all others by most recently used / updated.
+ return [...base].sort((a, b) => {
+ const aPinned = a.pinnedBy.includes("local") ? 1 : 0
+ const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ if (aPinned !== bPinned) {
+ return bPinned - aPinned
+ }
+
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
}, [templates, templateId, search, visibilityFilter, creatorFilter])
const handleSelect = (record: TemplateRecord) => {
@@ -146,6 +167,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
: [...record.pinnedBy, currentUserId]
try {
+ setPinningId(record.id)
const updated = await provider.saveTemplate({
id: record.id,
name: record.name,
@@ -163,6 +185,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
)
} catch {
// Silently ignore pin failures in this view
+ } finally {
+ setPinningId((current) => (current === record.id ? null : current))
}
}
@@ -387,37 +411,20 @@ export function TemplatesLibraryView({ onClose }: Props) {
{/* Current row */}
- {shouldShowCurrent && (
-
-
-
-
- {templateName}
-
-
- Current
-
-
-
- {currentTransformCount} transformation
- {currentTransformCount !== 1 ? "s" : ""}
-
-
-
+ {shouldShowCurrent && activeTemplate && (
+ {
+ // Current row is informational; selecting it is a no-op.
+ }}
+ onTogglePin={handleTogglePin}
+ isPinning={pinningId === activeTemplate.id}
+ onDelete={() => {
+ // Deletion for current row is disabled via props.
+ }}
+ isCurrent
+ canDelete={false}
+ />
)}
{/* Filtered templates */}
@@ -446,6 +453,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
record={record}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
+ isPinning={pinningId === record.id}
onDelete={async (r) => {
if (!provider) return
if (!provider.deleteTemplate) return
@@ -468,6 +476,9 @@ interface TemplateRowProps {
onSelect(record: TemplateRecord): void
onTogglePin(record: TemplateRecord): void
onDelete(record: TemplateRecord): void
+ isPinning: boolean
+ isCurrent?: boolean
+ canDelete?: boolean
}
function TemplateRow({
@@ -475,6 +486,9 @@ function TemplateRow({
onSelect,
onTogglePin,
onDelete,
+ isPinning,
+ isCurrent = false,
+ canDelete = true,
}: TemplateRowProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
@@ -482,45 +496,64 @@ function TemplateRow({
px="5"
py="4"
alignItems="center"
- cursor="pointer"
+ cursor={isCurrent ? "default" : "pointer"}
borderBottomWidth="1px"
borderColor="editorGray.200"
- _hover={{ bg: "editorGray.50" }}
- onClick={() => onSelect(record)}
+ bg={isCurrent ? "blue.50" : "transparent"}
+ _hover={isCurrent ? undefined : { bg: "editorGray.50" }}
+ onClick={() => {
+ if (!isCurrent) onSelect(record)
+ }}
>
{/* Pin */}
{
e.stopPropagation()
onTogglePin(record)
}}
>
-
+ {isPinning ? (
+
+ ) : (
+
+ )}
{/* Name + transform count */}
+
+
+ {record.name}
+
+ {isCurrent && (
+
+ Current
+
+ )}
+
- {record.name}
-
-
{record.transformations.length} transformation
{record.transformations.length !== 1 ? "s" : ""}
@@ -529,20 +562,24 @@ function TemplateRow({
{/* Creator */}
{record.createdBy.name || record.createdBy.email}
-
+
{record.createdBy.email}
@@ -554,9 +591,12 @@ function TemplateRow({
-
+
{record.isPrivate ? "Only to me" : "Shared with everyone"}
@@ -564,7 +604,10 @@ function TemplateRow({
{/* Last updated */}
-
+
{formatRelativeTime(record.updatedAt)}
@@ -613,11 +656,13 @@ function TemplateRow({
>
}
- color="red.500"
+ color={canDelete ? "red.500" : "gray.400"}
display="flex"
alignItems="center"
- _hover={{ bg: "red.50" }}
+ _hover={{ bg: canDelete ? "red.50" : "transparent" }}
+ isDisabled={!canDelete}
onClick={(e) => {
+ if (!canDelete) return
e.stopPropagation()
setShowDeleteConfirm(true)
}}
@@ -648,6 +693,11 @@ function TemplateRow({
size="md"
variant="ghost"
onClick={() => setShowDeleteConfirm(false)}
+ color="editorBattleshipGrey.500"
+ _hover={{
+ color: "editorBattleshipGrey.800",
+ bg: "editorGray.50",
+ }}
>
Cancel
From e7a2276038fc7aad7ad2269851c07205f2beea17 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:43:28 +0530
Subject: [PATCH 16/64] fix: localStorage provider pinning/unpinning should not
update updatedAt
---
.../src/components/templates/TemplatesLibraryView.tsx | 1 +
.../imagekit-editor-dev/src/storage/localStorage-provider.ts | 2 +-
packages/imagekit-editor-dev/src/storage/types.ts | 5 +++++
3 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index ae792fb..a4631be 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -178,6 +178,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
createdBy: record.createdBy,
updatedBy: record.updatedBy,
createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
})
setTemplates((prev) =>
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index f39516f..dc6ff09 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -88,7 +88,7 @@ export function createLocalStorageProvider(
transformations: record.transformations,
isPrivate: record.isPrivate ?? existing.isPrivate,
pinnedBy: record.pinnedBy ?? existing.pinnedBy,
- updatedAt: now,
+ updatedAt: record.updatedAt ?? now,
updatedBy: record.updatedBy ?? LOCAL_USER,
}
templates[index] = updated
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
index efe8b44..ab9fc02 100644
--- a/packages/imagekit-editor-dev/src/storage/types.ts
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -30,6 +30,11 @@ export type SaveTemplateInput = {
createdBy?: TemplateCreator
updatedBy?: TemplateCreator
createdAt?: number
+ /**
+ * Optional override for updatedAt. When provided, the local storage provider
+ * will respect this value instead of always touching updatedAt.
+ */
+ updatedAt?: number
}
export interface TemplateStorageProvider {
From 8c637d2eb223cf6c476f1fc07024ded814c2548a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 17:45:01 +0530
Subject: [PATCH 17/64] fix: lint fixes
---
.../components/header/TemplateNameInput.tsx | 4 ++-
.../components/header/TemplatesDropdown.tsx | 10 ++++++--
.../src/components/header/index.tsx | 12 +++++++--
.../src/hooks/useAutoSaveTemplate.ts | 4 ++-
.../imagekit-editor-dev/src/schema/index.ts | 25 ++++++++-----------
.../src/storage/localStorage-provider.ts | 3 +--
6 files changed, 35 insertions(+), 23 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
index 1304299..0006e76 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -79,7 +79,9 @@ export function TemplateNameInput() {
variant="unstyled"
fontWeight="medium"
fontSize="md"
- color={isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"}
+ color={
+ isDefault ? "editorBattleshipGrey.500" : "editorBattleshipGrey.900"
+ }
placeholder={UNTITLED}
_placeholder={{ color: "editorBattleshipGrey.500" }}
width="auto"
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index e94cbe5..639c5fa 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -35,7 +35,9 @@ interface TemplatesDropdownProps {
onViewAllTemplates?: () => void
}
-export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps) {
+export function TemplatesDropdown({
+ onViewAllTemplates,
+}: TemplatesDropdownProps) {
const provider = useTemplateStorage()
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
@@ -171,7 +173,11 @@ export function TemplatesDropdown({ onViewAllTemplates }: TemplatesDropdownProps
overflow="hidden"
borderWidth="0"
outline="none"
- _focus={{ boxShadow: "lg", outline: "none", borderColor: "transparent" }}
+ _focus={{
+ boxShadow: "lg",
+ outline: "none",
+ borderColor: "transparent",
+ }}
>
void
}
-export const Header = ({ onClose, exportOptions, onViewAllTemplates }: HeaderProps) => {
+export const Header = ({
+ onClose,
+ exportOptions,
+ onViewAllTemplates,
+}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const provider = useTemplateStorage()
diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
index 3bc3620..3d0f36e 100644
--- a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
+++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
@@ -64,7 +64,9 @@ export function useAutoSaveTemplate() {
} catch (err) {
setSyncStatus(
"error",
- err instanceof Error ? err.message : "Failed to auto-save template",
+ err instanceof Error
+ ? err.message
+ : "Failed to auto-save template",
)
}
}, DEBOUNCE_MS)
diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts
index de2470f..1455b1e 100644
--- a/packages/imagekit-editor-dev/src/schema/index.ts
+++ b/packages/imagekit-editor-dev/src/schema/index.ts
@@ -709,21 +709,16 @@ const baseTransformationSchema: TransformationSchema[] = [
// z.preprocess normalises legacy string values that were coerced
// from the array before this fix (e.g. "horizontal",
// "horizontal,vertical", or corrupted "h,,,o,r,i,z,n,t,a,l,...").
- flip: z.preprocess(
- (val) => {
- if (Array.isArray(val)) return val
- if (typeof val === "string" && val) {
- return val
- .split(",")
- .map((s) => s.trim())
- .filter(
- (s) => s === "horizontal" || s === "vertical",
- )
- }
- return []
- },
- z.array(z.enum(["horizontal", "vertical"])).optional(),
- ),
+ flip: z.preprocess((val) => {
+ if (Array.isArray(val)) return val
+ if (typeof val === "string" && val) {
+ return val
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s === "horizontal" || s === "vertical")
+ }
+ return []
+ }, z.array(z.enum(["horizontal", "vertical"])).optional()),
})
.refine(
(val) => {
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index dc6ff09..f40cad9 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -20,8 +20,7 @@ function normalizeRecord(raw: Record): TemplateRecord {
return {
id: (raw.id as string) || generateId(),
clientNumber: (raw.clientNumber as string) || "local",
- isPrivate:
- raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
+ isPrivate: raw.isPrivate !== undefined ? (raw.isPrivate as boolean) : true,
name: (raw.name as string) || "",
transformations:
(raw.transformations as TemplateRecord["transformations"]) || [],
From cf0e15174681899e33f0a76da330117d3178d11f Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 18:21:36 +0530
Subject: [PATCH 18/64] feat: polished template library ui blocks
---
.../common/MultiSelectListField.tsx | 2 +-
.../src/components/editor/layout.tsx | 14 +----
.../templates/TemplatesLibraryView.tsx | 54 ++++++++++++++-----
3 files changed, 44 insertions(+), 26 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
index 8bc331b..c32ff37 100644
--- a/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/MultiSelectListField.tsx
@@ -130,7 +130,7 @@ export const MultiSelectListField: React.FC = ({
-
+
{opt.label}
{opt.email && (
diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
index 4150f82..fd8189f 100644
--- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx
@@ -1,5 +1,4 @@
-import { Box, Flex, IconButton } from "@chakra-ui/react"
-import { PiX } from "@react-icons/all-files/pi/PiX"
+import { Box, Flex } from "@chakra-ui/react"
import { useEffect, useState } from "react"
import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
@@ -89,17 +88,6 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) {
flexDirection="column"
position="relative"
>
- }
- size="sm"
- variant="ghost"
- position="absolute"
- top="3"
- right="3"
- zIndex={1}
- onClick={() => setIsTemplatesOpen(false)}
- />
([])
const [pinningId, setPinningId] = useState(null)
- const { loadTemplate, setTemplateName, setTemplateId } = useEditorStore()
+ const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
+ useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
const templateName = useEditorStore((s) => s.templateName)
const transformations = useEditorStore((s) => s.transformations)
@@ -209,18 +212,45 @@ export function TemplatesLibraryView({ onClose }: Props) {
flexDirection="column"
gap="4"
>
-
- }
+ color="editorBattleshipGrey.500"
+ _hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
+ px="0"
+ >
+ Go back
+
+
+
+
+
+ All templates
+
+
+ Browse and load templates created by you or shared with you.
+
+
+ }
+ px="4"
+ onClick={() => {
+ resetToNewTemplate()
+ onClose()
+ }}
>
- All templates
-
-
- Browse and load templates shared with you or created by your team.
-
-
+ New template
+
+
{/* Controls bar */}
Date: Tue, 17 Mar 2026 19:00:10 +0530
Subject: [PATCH 19/64] feat: compact layout for templates dropdown in the
navbar
---
.../components/header/TemplatesDropdown.tsx | 229 ++++++++++++++----
1 file changed, 187 insertions(+), 42 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 639c5fa..864d37f 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -5,6 +5,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
+ Avatar,
Badge,
Box,
Button,
@@ -18,18 +19,23 @@ import {
PopoverBody,
PopoverContent,
PopoverTrigger,
+ Spinner,
Text,
useDisclosure,
} from "@chakra-ui/react"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
import { PiPlus } from "@react-icons/all-files/pi/PiPlus"
-import React, { useCallback, useEffect, useRef, useState } from "react"
+import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
+import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
-const MAX_VISIBLE = 8
+const MAX_VISIBLE = 5
interface TemplatesDropdownProps {
onViewAllTemplates?: () => void
@@ -42,6 +48,7 @@ export function TemplatesDropdown({
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
const [search, setSearch] = useState("")
+ const [pinningId, setPinningId] = useState(null)
const searchRef = useRef(null)
const cancelRef = useRef(null)
@@ -87,10 +94,26 @@ export function TemplatesDropdown({
? activeTemplate.transformations.length
: transformations.length
- const filtered = templates
- .filter((t) => t.id !== templateId)
- .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
- .slice(0, MAX_VISIBLE)
+ const filtered = useMemo(() => {
+ const base = templates
+ .filter((t) => t.id !== templateId)
+ .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
+
+ // Sort by: pinned first, then by most recently used/updated
+ return [...base]
+ .sort((a, b) => {
+ const aPinned = a.pinnedBy.includes("local") ? 1 : 0
+ const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ if (aPinned !== bPinned) {
+ return bPinned - aPinned
+ }
+
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+ })
+ .slice(0, MAX_VISIBLE)
+ }, [templates, templateId, search])
const doLoadTemplate = (record: TemplateRecord) => {
loadTemplate(record.transformations)
@@ -114,6 +137,39 @@ export function TemplatesDropdown({
onClose()
}
+ const handleTogglePin = async (record: TemplateRecord) => {
+ if (!provider) return
+ const currentUserId = "local"
+ const isPinned = record.pinnedBy.includes(currentUserId)
+ const nextPinnedBy = isPinned
+ ? record.pinnedBy.filter((id) => id !== currentUserId)
+ : [...record.pinnedBy, currentUserId]
+
+ try {
+ setPinningId(record.id)
+ const updated = await provider.saveTemplate({
+ id: record.id,
+ name: record.name,
+ transformations: record.transformations,
+ clientNumber: record.clientNumber,
+ isPrivate: record.isPrivate,
+ pinnedBy: nextPinnedBy,
+ createdBy: record.createdBy,
+ updatedBy: record.updatedBy,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ })
+
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === updated.id ? updated : t)),
+ )
+ } catch {
+ // ignore pin failures in dropdown
+ } finally {
+ setPinningId((current) => (current === record.id ? null : current))
+ }
+ }
+
const handleSaveAndContinue = async () => {
if (!provider || !pendingTemplate) return
const state = useEditorStore.getState()
@@ -167,7 +223,7 @@ export function TemplatesDropdown({
-
-
-
+
+
+
setSearch(e.target.value)}
- variant="filled"
- bg="editorGray.200"
- _focus={{ bg: "editorGray.200" }}
+ bg="white"
+ borderColor="gray.200"
borderRadius="md"
+ px="3"
fontSize="sm"
+ fontWeight="400"
+ _placeholder={{ fontWeight: "400" }}
+ _hover={{ borderColor: "gray.300" }}
+ _focus={{
+ borderColor: "blue.500",
+ boxShadow: "0 0 0 1px #3182ce",
+ }}
/>
}
- onClick={handleNewTemplate}
- variant="ghost"
+ size="md"
colorScheme="blue"
+ variant="outline"
+ leftIcon={ }
+ px="4"
flexShrink={0}
fontWeight="normal"
+ onClick={handleNewTemplate}
>
New
@@ -225,17 +285,27 @@ export function TemplatesDropdown({
{shouldShowCurrent && (
-
-
+ {/* Visibility Icon (fallback to private when unknown) */}
+
+
+ {/* Name + badge */}
+
+
-
- {currentTransformCount} transformation
- {currentTransformCount !== 1 ? "s" : ""}
-
+
+ {/* Transform count on the right */}
+
+ {currentTransformCount} transformation
+ {currentTransformCount !== 1 ? "s" : ""}
+
)}
@@ -273,21 +345,94 @@ export function TemplatesDropdown({
handleSelect(record)}
+ transition="background-color 0.15s"
+ role="group"
>
-
-
- {record.name}
-
-
- {record.transformations.length} transformation
- {record.transformations.length !== 1 ? "s" : ""}
-
-
+ {/* Visibility Icon */}
+
+
+ {/* Template name */}
+
+ {record.name}
+
+
+ {/* Creator on hover + pin (always visible for pinned, hover for others) */}
+
+ {/* Creator: only on hover */}
+
+
+
+ {record.createdBy.name || record.createdBy.email}
+
+
+
+ {/* Pin */}
+ {
+ e.stopPropagation()
+ handleTogglePin(record)
+ }}
+ >
+ {pinningId === record.id ? (
+
+ ) : (
+
+ )}
+
+
))
)}
@@ -301,7 +446,7 @@ export function TemplatesDropdown({
}
px="4"
flexShrink={0}
@@ -442,10 +442,11 @@ export function TemplatesDropdown({
{onViewAllTemplates ? (
<>
-
+
}
color="editorGray.700"
fontWeight="normal"
onClick={() => {
From 50552da836eb122d9862e19fcb9761e67ac6a4a3 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:07:27 +0530
Subject: [PATCH 21/64] fix: font size increased for better visibility
---
.../templates/TemplatesLibraryView.tsx | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index d147fb6..9740421 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -548,11 +548,11 @@ function TemplateRow({
}}
>
{isPinning ? (
-
+
) : (
{record.createdBy.name || record.createdBy.email}
@@ -621,11 +621,11 @@ function TemplateRow({
{record.isPrivate ? "Only to me" : "Shared with everyone"}
@@ -636,7 +636,7 @@ function TemplateRow({
{/* Last updated */}
{formatRelativeTime(record.updatedAt)}
From e01ea30998264831dda5a0b6085ab502306335da Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:10:04 +0530
Subject: [PATCH 22/64] fix: table header and row cells justfied left
---
.../components/templates/TemplatesLibraryView.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 9740421..03de4c0 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -426,17 +426,19 @@ export function TemplatesLibraryView({ onClose }: Props) {
textTransform="uppercase"
letterSpacing="0.06em"
>
-
- Name
+ {/* Pin column spacer to align with row */}
+
+
+ Name
- Created by
+ Created by
- Visibility
+ Visibility
- Last updated
+ Last updated
From 428229b064f069936bdfdcd7b3e803ad13e9c0e9 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:15:08 +0530
Subject: [PATCH 23/64] fix: remove border from all changes saved popover
---
.../imagekit-editor-dev/src/components/header/TemplateStatus.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 07656e4..147f20e 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -149,6 +149,7 @@ export function TemplateStatus() {
width="auto"
maxW="xs"
shadow="lg"
+ border="none"
_focus={{ boxShadow: "lg" }}
>
From 99cbccf162a064be583c467f1313c9db2295c8a6 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Tue, 17 Mar 2026 20:19:42 +0530
Subject: [PATCH 24/64] feat: added visibility icon next to the template name
---
.../src/components/header/index.tsx | 38 ++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index c82fde2..0230cab 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -9,8 +9,10 @@ import {
MenuList,
Spacer,
} from "@chakra-ui/react"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiX } from "@react-icons/all-files/pi/PiX"
-import React from "react"
+import React, { useEffect, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import {
type FileElement,
@@ -60,8 +62,35 @@ export const Header = ({
onViewAllTemplates,
}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
+ const templateId = useEditorStore((s) => s.templateId)
const provider = useTemplateStorage()
+ const [isPrivate, setIsPrivate] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId) {
+ setIsPrivate(null)
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled) return
+ setIsPrivate(record ? record.isPrivate : null)
+ })
+ .catch(() => {
+ if (cancelled) return
+ setIsPrivate(null)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId])
+
return (
{provider ? (
+ {templateId && (
+
+ )}
From 437402374aee413c74c06ff0708eef0bb89e6f6f Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Wed, 18 Mar 2026 15:13:41 +0530
Subject: [PATCH 25/64] feat: navbar ui improvement
---
.../components/header/TemplatesDropdown.tsx | 21 ++++++--
.../src/components/header/index.tsx | 54 ++++++++++++++-----
2 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 27d2d66..49391d2 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -208,14 +208,29 @@ export function TemplatesDropdown({
>
+
+
+ Templates
+
{provider ? (
-
- {templateId && (
-
- )}
-
-
-
+ <>
+
+ {templateId && (
+
+ )}
+
+
+
+ }
+ variant="ghost"
+ height="full"
+ width="20"
+ borderRadius="0"
+ size="md"
+ color="editorBattleshipGrey.500"
+ />
+
+
+
+
+
+ >
) : null}
-
+
+
+
{exportOptions
?.filter((exportOption) =>
From 70d1b40bbbad67c90435911533eef889d2e4e81a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Wed, 18 Mar 2026 15:18:24 +0530
Subject: [PATCH 26/64] fix: reduce margin left on the name
---
packages/imagekit-editor-dev/src/components/header/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 6d6af4e..bb5509f 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -108,7 +108,7 @@ export const Header = ({
>
{provider ? (
<>
-
+
{templateId && (
Date: Thu, 19 Mar 2026 13:06:13 +0530
Subject: [PATCH 27/64] chore: working state commit
---
.../src/components/header/NavbarItem.tsx | 48 +++
.../src/components/header/SettingsModal.tsx | 290 ++++++++++++++++++
.../src/components/header/TemplateStatus.tsx | 14 +-
.../components/header/TemplatesDropdown.tsx | 15 +-
.../src/components/header/index.tsx | 112 +++----
5 files changed, 421 insertions(+), 58 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
create mode 100644 packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
diff --git a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
new file mode 100644
index 0000000..08bb59a
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
@@ -0,0 +1,48 @@
+import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react"
+import type React from "react"
+
+interface NavbarItemProps extends Omit {
+ icon?: React.ReactElement
+ label: string
+ variant?: "button" | "icon"
+}
+
+export const NavbarItem = ({
+ icon,
+ label,
+ variant = "button",
+ children,
+ ...props
+}: NavbarItemProps) => {
+ const commonStyles = {
+ variant: "ghost" as const,
+ borderRadius: "md" as const,
+ px: "4" as const,
+ py: "2" as const,
+ mx: "2" as const,
+ fontSize: "sm" as const,
+ fontWeight: "medium" as const,
+ _hover: {
+ bg: "editorBattleshipGrey.50",
+ },
+ }
+
+ // If only icon is provided (no children or label to display), use icon variant
+ if (variant === "icon" || (!children && icon && !label)) {
+ return (
+ : undefined}
+ color="editorBattleshipGrey.500"
+ {...commonStyles}
+ {...props}
+ />
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
new file mode 100644
index 0000000..2d44cfc
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -0,0 +1,290 @@
+import {
+ Box,
+ Button,
+ Flex,
+ FormControl,
+ FormLabel,
+ Icon,
+ IconButton,
+ Input,
+ Text,
+} from "@chakra-ui/react"
+import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
+import { PiLock } from "@react-icons/all-files/pi/PiLock"
+import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
+import { PiX } from "@react-icons/all-files/pi/PiX"
+import { useEffect, useState } from "react"
+import Select from "react-select"
+import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useEditorStore } from "../../store"
+
+interface SettingsModalProps {
+ onClose: () => void
+}
+
+export function SettingsModal({ onClose }: SettingsModalProps) {
+ const provider = useTemplateStorage()
+ const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
+ const setTemplateName = useEditorStore((s) => s.setTemplateName)
+ const transformations = useEditorStore((s) => s.transformations)
+ const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
+ const resetToNewTemplate = useEditorStore((s) => s.resetToNewTemplate)
+
+ const [localName, setLocalName] = useState(templateName)
+ const [localVisibility, setLocalVisibility] = useState<"everyone" | "onlyMe">(
+ "onlyMe",
+ )
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // Fetch current template visibility
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId) {
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled || !record) return
+ setLocalVisibility(record.isPrivate ? "onlyMe" : "everyone")
+ })
+ .catch(() => {
+ // Ignore errors
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId])
+
+ const handleSave = async () => {
+ if (!provider || !localName.trim()) return
+
+ setIsSaving(true)
+ setSyncStatus("saving")
+
+ try {
+ await provider.saveTemplate({
+ id: templateId ?? undefined,
+ name: localName.trim(),
+ transformations: transformations.map(({ id: _id, ...rest }) => rest),
+ isPrivate: localVisibility === "onlyMe",
+ })
+
+ setTemplateName(localName.trim())
+ setSyncStatus("saved")
+ onClose()
+ } catch (err) {
+ setSyncStatus(
+ "error",
+ err instanceof Error ? err.message : "Failed to save",
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!provider || !templateId) return
+
+ setIsDeleting(true)
+
+ try {
+ await provider.deleteTemplate(templateId)
+ resetToNewTemplate()
+ onClose()
+ } catch (err) {
+ console.error("Failed to delete template:", err)
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation()
+ onClose()
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [onClose])
+
+ return (
+
+ e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+ Template Settings
+
+ }
+ aria-label="Close settings"
+ />
+
+
+ {/* Content */}
+
+
+ {/* Template Name */}
+
+
+ Template Name
+
+ setLocalName(e.target.value)}
+ placeholder="Enter template name"
+ fontSize="sm"
+ />
+
+
+ {/* Visibility */}
+
+
+ Visibility
+
+
+
+
+
+ {/* Footer */}
+
+ }
+ onClick={handleDelete}
+ isLoading={isDeleting}
+ isDisabled={!templateId || isSaving}
+ >
+ Delete
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 147f20e..7a4dc0b 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -70,7 +70,12 @@ export function TemplateStatus() {
// "Saving…" is a transient text-only state — no icon yet
if (notificationVisible && syncStatus === "saving") {
return (
-
+
Saving…
)
@@ -165,7 +170,12 @@ export function TemplateStatus() {
{notifText && (
-
+
{notifText}
)}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 49391d2..3ef8c22 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -80,6 +80,13 @@ export function TemplatesDropdown({
}
}, [isOpen, fetchTemplates])
+ useEffect(() => {
+ // Refetch templates when sync status changes to "saved" to reflect updates
+ if (syncStatus === "saved") {
+ fetchTemplates()
+ }
+ }, [syncStatus, fetchTemplates])
+
if (!provider) return null
const activeTemplate = templateId
@@ -213,9 +220,11 @@ export function TemplatesDropdown({
alignItems="center"
gap="2"
cursor="pointer"
- height="full"
- px="4"
- _hover={{ bg: "editorGray.100" }}
+ borderRadius="md"
+ paddingX="4"
+ paddingY="2"
+ marginX="2"
+ _hover={{ bg: "editorGray.200" }}
transition="background-color 0.15s"
aria-label="Open templates dropdown"
>
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index bb5509f..d82d394 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -1,9 +1,7 @@
import {
- Button,
Divider,
Flex,
Icon,
- IconButton,
Menu,
MenuButton,
MenuItem,
@@ -21,6 +19,8 @@ import {
type RequiredMetadata,
useEditorStore,
} from "../../store"
+import { NavbarItem } from "./NavbarItem"
+import { SettingsModal } from "./SettingsModal"
import { TemplateNameInput } from "./TemplateNameInput"
import { TemplateStatus } from "./TemplateStatus"
import { TemplatesDropdown } from "./TemplatesDropdown"
@@ -65,9 +65,11 @@ export const Header = ({
}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
+ const syncStatus = useEditorStore((s) => s.syncStatus)
const provider = useTemplateStorage()
const [isPrivate, setIsPrivate] = useState(null)
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
useEffect(() => {
let cancelled = false
@@ -93,6 +95,30 @@ export const Header = ({
}
}, [provider, templateId])
+ // Refetch template visibility when it's saved
+ useEffect(() => {
+ let cancelled = false
+
+ if (!provider || !templateId || syncStatus !== "saved") {
+ return
+ }
+
+ provider
+ .getTemplate(templateId)
+ .then((record) => {
+ if (cancelled) return
+ setIsPrivate(record ? record.isPrivate : null)
+ })
+ .catch(() => {
+ if (cancelled) return
+ setIsPrivate(null)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [provider, templateId, syncStatus])
+
return (
- }
- variant="ghost"
- height="full"
- width="20"
- borderRadius="0"
- size="md"
- color="editorBattleshipGrey.500"
+ }
+ variant="icon"
+ onClick={() => setIsSettingsOpen(true)}
/>
-
+
>
) : null}
@@ -157,21 +182,11 @@ export const Header = ({
)
.map((exportOption) => (
-
{exportOption.type === "button" ? (
-
+ />
) : (
))}
-
- }
- aria-label="Close Button"
+
+ }
+ label="Close Button"
onClick={onClose}
- variant="ghost"
- fontWeight="normal"
- height="full"
- borderRadius="0"
- px="8"
- size="sm"
- >
- Close
-
+ />
+ {isSettingsOpen && (
+ setIsSettingsOpen(false)} />
+ )}
)
}
From 6ff72a2294401292df23ce8da0ab0508b4c679f7 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:07:25 +0530
Subject: [PATCH 28/64] fix: close button label
---
packages/imagekit-editor-dev/src/components/header/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index d82d394..312bf32 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -253,7 +253,7 @@ export const Header = ({
/>
}
- label="Close Button"
+ label="Close"
onClick={onClose}
/>
{isSettingsOpen && (
From 96e349f4553ed0f185c39860e84cac698dfa073b Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:22:51 +0530
Subject: [PATCH 29/64] fix: all lint fixes
---
.../src/components/common/FilterChipsField.tsx | 10 +---------
.../src/components/common/MultiSelectListField.tsx | 3 ---
.../src/components/header/TemplateNameInput.tsx | 3 ++-
.../src/components/header/TemplateStatus.tsx | 2 +-
.../src/components/header/TemplatesDropdown.tsx | 7 +++----
.../src/components/templates/TemplatesLibraryView.tsx | 6 ------
.../src/context/TemplateStorageContext.tsx | 3 ++-
7 files changed, 9 insertions(+), 25 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
index 58ba8a9..bf0e5e7 100644
--- a/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
+++ b/packages/imagekit-editor-dev/src/components/common/FilterChipsField.tsx
@@ -66,21 +66,13 @@ export const FilterChipsField: React.FC = ({
}
return (
-
+
{options.map((opt) => {
const isChecked = safeValue.includes(opt.value)
const disabled = opt.isDisabled || (!isChecked && isMaxed)
return (
= ({
return (
t.id === templateId) ?? null)
: null
@@ -123,6 +121,8 @@ export function TemplatesDropdown({
.slice(0, MAX_VISIBLE)
}, [templates, templateId, search])
+ if (!provider) return null
+
const doLoadTemplate = (record: TemplateRecord) => {
loadTemplate(record.transformations)
setTemplateName(record.name)
@@ -376,7 +376,6 @@ export function TemplatesDropdown({
_hover={{ bg: "editorGray.100" }}
onClick={() => handleSelect(record)}
transition="background-color 0.15s"
- role="group"
>
{/* Visibility Icon */}
s.templateId)
- const templateName = useEditorStore((s) => s.templateName)
- const transformations = useEditorStore((s) => s.transformations)
const isPristine = useEditorStore((s) => s.isPristine)
const syncStatus = useEditorStore((s) => s.syncStatus)
@@ -92,10 +90,6 @@ export function TemplatesLibraryView({ onClose }: Props) {
? (templates.find((t) => t.id === templateId) ?? null)
: null
- const currentTransformCount = activeTemplate
- ? activeTemplate.transformations.length
- : transformations.length
-
const uniqueCreators = useMemo(() => {
const seen = new Map()
for (const t of templates) {
diff --git a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
index c591f83..64aa75d 100644
--- a/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
+++ b/packages/imagekit-editor-dev/src/context/TemplateStorageContext.tsx
@@ -1,4 +1,5 @@
-import React, { createContext, useContext } from "react"
+import type React from "react"
+import { createContext, useContext } from "react"
import type { TemplateStorageProvider } from "../storage"
const TemplateStorageContext = createContext(
From 0dbc50da7e360fcf4c6487f923cac49aefdae77b Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 19 Mar 2026 13:39:33 +0530
Subject: [PATCH 30/64] chore: update husky hook
---
.husky/pre-commit | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.husky/pre-commit b/.husky/pre-commit
index af5adff..6684d66 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,5 @@
-lint-staged
\ No newline at end of file
+# Run lint autofixes
+yarn lint:fix
+
+# Check lint again
+yarn lint
\ No newline at end of file
From 3b85ae5db3f3cce9dabc9df8ec51df4b5cc0188a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 2 Apr 2026 15:30:58 +0530
Subject: [PATCH 31/64] feat: working commit 1 for creative templates
---
examples/react-example/src/index.tsx | 3 +-
.../src/ImageKitEditor.tsx | 61 ++++-----
.../src/components/header/NavbarItem.tsx | 73 ++++++-----
.../src/components/header/SettingsModal.tsx | 105 ++++++++++++++--
.../components/header/TemplateNameInput.tsx | 9 +-
.../src/components/header/TemplateStatus.tsx | 2 +-
.../components/header/TemplatesDropdown.tsx | 76 ++++++-----
.../src/components/header/index.tsx | 33 ++++-
.../templates/TemplatesLibraryView.tsx | 119 ++++++++++++------
.../src/hooks/useAutoSaveTemplate.ts | 13 +-
.../src/hooks/useSaveTemplate.ts | 25 +++-
packages/imagekit-editor-dev/src/index.tsx | 10 +-
.../imagekit-editor-dev/src/storage/index.ts | 7 ++
.../src/storage/localStorage-provider.ts | 35 +++++-
.../src/storage/serializeTransformations.ts | 12 ++
.../src/storage/templateAccessError.ts | 48 +++++++
.../imagekit-editor-dev/src/storage/types.ts | 18 ++-
packages/imagekit-editor-dev/src/store.ts | 32 +++++
packages/imagekit-editor/package.json | 2 +-
19 files changed, 518 insertions(+), 165 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/storage/serializeTransformations.ts
create mode 100644 packages/imagekit-editor-dev/src/storage/templateAccessError.ts
diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx
index bd21603..c84c050 100644
--- a/examples/react-example/src/index.tsx
+++ b/examples/react-example/src/index.tsx
@@ -1,5 +1,6 @@
import { Icon } from "@chakra-ui/react"
import {
+ createLocalStorageProvider,
ImageKitEditor,
type ImageKitEditorProps,
type ImageKitEditorRef,
@@ -148,7 +149,7 @@ function App() {
console.log("Signed URL", request.url)
return Promise.resolve(request.url)
},
- storageProvider: "localStorage",
+ templateStorage: createLocalStorageProvider(),
})
}, [handleAddImage])
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index 4f0b836..9c04d40 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -11,8 +11,7 @@ import { EditorLayout, EditorWrapper } from "./components/editor"
import type { HeaderProps } from "./components/header"
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
import {
- createLocalStorageProvider,
- type LocalStorageProviderOptions,
+ applyTemplateStorageAccessFailure,
type TemplateStorageProvider,
} from "./storage"
import {
@@ -84,24 +83,19 @@ interface EditorProps {
exportOptions?: HeaderProps["exportOptions"]
focusObjects?: ReadonlyArray
onClose: (args: { dirty: boolean; destroy: () => void }) => void
- storageProvider?: "localStorage" | "library"
- libraryStorage?: TemplateStorageProvider
- localStorageKeys?: LocalStorageProviderOptions
+ /**
+ * Template persistence (list/save/delete/pin). Implemented by the host app —
+ * the editor does not perform media library or other remote API calls itself.
+ * Omit or pass `null` to disable template sync UI.
+ */
+ templateStorage?: TemplateStorageProvider | null
}
function ImageKitEditorImpl(
props: EditorProps,
ref: React.Ref,
) {
- const {
- theme,
- initialImages,
- signer,
- focusObjects,
- storageProvider,
- libraryStorage,
- localStorageKeys,
- } = props
+ const { theme, initialImages, signer, focusObjects, templateStorage } = props
const {
addImage,
addImages,
@@ -112,33 +106,40 @@ function ImageKitEditorImpl(
loadTemplate,
} = useEditorStore()
- const resolvedProvider = useMemo(() => {
- if (storageProvider === "localStorage") {
- return createLocalStorageProvider(localStorageKeys)
- }
- if (storageProvider === "library" && libraryStorage) {
- return libraryStorage
- }
- return null
- }, [storageProvider, libraryStorage, localStorageKeys])
+ const resolvedProvider = useMemo(
+ () => templateStorage ?? null,
+ [templateStorage],
+ )
const saveTemplateImperative = useCallback(async () => {
if (!resolvedProvider) return
- const state = useEditorStore.getState()
- const { setSyncStatus, setTemplateId, setTemplateName } = state
+ const s = useEditorStore.getState()
+ if (s.templateStorageWriteBlocked) return
+
+ const {
+ setSyncStatus,
+ setTemplateId,
+ setTemplateName,
+ denyTemplateStorageAccess,
+ } = s
setSyncStatus("saving")
try {
const saved = await resolvedProvider.saveTemplate({
- id: state.templateId ?? undefined,
- name: state.templateName,
- transformations: state.transformations.map(
- ({ id: _id, ...rest }) => rest,
- ),
+ id: s.templateId ?? undefined,
+ name: s.templateName,
+ transformations: s.transformations.map(({ id: _id, ...rest }) => rest),
})
setTemplateId(saved.id)
setTemplateName(saved.name)
setSyncStatus("saved")
} catch (err) {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setSyncStatus(
"error",
err instanceof Error ? err.message : "Failed to save template",
diff --git a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
index 08bb59a..10aabcb 100644
--- a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
@@ -1,5 +1,6 @@
import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react"
import type React from "react"
+import { forwardRef } from "react"
interface NavbarItemProps extends Omit {
icon?: React.ReactElement
@@ -7,42 +8,48 @@ interface NavbarItemProps extends Omit {
variant?: "button" | "icon"
}
-export const NavbarItem = ({
- icon,
- label,
- variant = "button",
- children,
- ...props
-}: NavbarItemProps) => {
- const commonStyles = {
- variant: "ghost" as const,
- borderRadius: "md" as const,
- px: "4" as const,
- py: "2" as const,
- mx: "2" as const,
- fontSize: "sm" as const,
- fontWeight: "medium" as const,
- _hover: {
- bg: "editorBattleshipGrey.50",
- },
- }
+export const NavbarItem = forwardRef(
+ function NavbarItem(
+ { icon, label, variant = "button", children, ...props },
+ ref,
+ ) {
+ const commonStyles = {
+ variant: "ghost" as const,
+ borderRadius: "md" as const,
+ px: "4" as const,
+ py: "2" as const,
+ mx: "2" as const,
+ fontSize: "sm" as const,
+ fontWeight: "medium" as const,
+ _hover: {
+ bg: "editorBattleshipGrey.50",
+ },
+ }
+
+ // If only icon is provided (no children or label to display), use icon variant
+ if (variant === "icon" || (!children && icon && !label)) {
+ return (
+ : undefined}
+ color="editorBattleshipGrey.500"
+ {...commonStyles}
+ {...props}
+ />
+ )
+ }
- // If only icon is provided (no children or label to display), use icon variant
- if (variant === "icon" || (!children && icon && !label)) {
return (
- : undefined}
- color="editorBattleshipGrey.500"
{...commonStyles}
{...props}
- />
+ >
+ {children || label}
+
)
- }
-
- return (
-
- )
-}
+ },
+)
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 2d44cfc..9fcbbf2 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -13,72 +13,136 @@ import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
import { PiX } from "@react-icons/all-files/pi/PiX"
-import { useEffect, useState } from "react"
+import { useEffect, useRef, useState } from "react"
import Select from "react-select"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
+function visibilityFromKnownPrivate(
+ isPrivate: boolean | null,
+): "everyone" | "onlyMe" {
+ if (isPrivate === false) {
+ return "everyone"
+ }
+ return "onlyMe"
+}
+
interface SettingsModalProps {
onClose: () => void
+ /** From header refetch of template visibility; seeds the dropdown before async getTemplate completes. */
+ knownIsPrivate: boolean | null
}
-export function SettingsModal({ onClose }: SettingsModalProps) {
+export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
const provider = useTemplateStorage()
const templateId = useEditorStore((s) => s.templateId)
const templateName = useEditorStore((s) => s.templateName)
const setTemplateName = useEditorStore((s) => s.setTemplateName)
+ const setTemplateId = useEditorStore((s) => s.setTemplateId)
const transformations = useEditorStore((s) => s.transformations)
const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
const resetToNewTemplate = useEditorStore((s) => s.resetToNewTemplate)
+ const denyTemplateStorageAccess = useEditorStore(
+ (s) => s.denyTemplateStorageAccess,
+ )
+ const templateStorageWriteBlocked = useEditorStore(
+ (s) => s.templateStorageWriteBlocked,
+ )
+
+ // Stable ref so the getTemplate effect doesn't re-run when onClose identity changes.
+ const onCloseRef = useRef(onClose)
+ useEffect(() => {
+ onCloseRef.current = onClose
+ })
const [localName, setLocalName] = useState(templateName)
const [localVisibility, setLocalVisibility] = useState<"everyone" | "onlyMe">(
- "onlyMe",
+ () => visibilityFromKnownPrivate(knownIsPrivate),
)
+ const [canChangeVisibility, setCanChangeVisibility] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
- // Fetch current template visibility
+ useEffect(() => {
+ setLocalName(templateName)
+ }, [templateName])
+
+ useEffect(() => {
+ setLocalVisibility(visibilityFromKnownPrivate(knownIsPrivate))
+ }, [knownIsPrivate])
+
+ // Authoritative visibility from API (may arrive after header snapshot).
+ // onClose is intentionally read via ref so its identity change never re-triggers this effect.
useEffect(() => {
let cancelled = false
if (!provider || !templateId) {
+ setCanChangeVisibility(true)
return
}
provider
.getTemplate(templateId)
.then((record) => {
- if (cancelled || !record) return
+ if (cancelled) return
+ if (!record) {
+ setCanChangeVisibility(true)
+ return
+ }
setLocalVisibility(record.isPrivate ? "onlyMe" : "everyone")
+ console.log(
+ provider?.getCurrentUserSession(),
+ record.createdBy.userId === provider?.getCurrentUserSession()?.id,
+ )
+ setCanChangeVisibility(
+ record.createdBy.userId === provider?.getCurrentUserSession()?.id,
+ )
})
- .catch(() => {
- // Ignore errors
+ .catch((err) => {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ onCloseRef.current()
+ }
})
return () => {
cancelled = true
}
- }, [provider, templateId])
+ // denyTemplateStorageAccess is a stable Zustand action — safe to include.
+ // onClose is intentionally excluded; we use onCloseRef instead.
+ }, [provider, templateId, denyTemplateStorageAccess])
const handleSave = async () => {
- if (!provider || !localName.trim()) return
+ if (!provider || !localName.trim() || templateStorageWriteBlocked) return
setIsSaving(true)
setSyncStatus("saving")
try {
- await provider.saveTemplate({
+ const saved = await provider.saveTemplate({
id: templateId ?? undefined,
name: localName.trim(),
transformations: transformations.map(({ id: _id, ...rest }) => rest),
isPrivate: localVisibility === "onlyMe",
})
+ setTemplateId(saved.id)
setTemplateName(localName.trim())
setSyncStatus("saved")
onClose()
} catch (err) {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ onClose()
+ return
+ }
setSyncStatus(
"error",
err instanceof Error ? err.message : "Failed to save",
@@ -98,6 +162,14 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
resetToNewTemplate()
onClose()
} catch (err) {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ onClose()
+ return
+ }
console.error("Failed to delete template:", err)
} finally {
setIsDeleting(false)
@@ -126,7 +198,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
display="flex"
alignItems="center"
justifyContent="center"
- zIndex={1400}
+ zIndex={1500}
onClick={onClose}
>
@@ -278,7 +357,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
size="md"
onClick={handleSave}
isLoading={isSaving}
- isDisabled={!localName.trim() || isDeleting}
+ isDisabled={
+ !localName.trim() || isDeleting || templateStorageWriteBlocked
+ }
>
Save
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
index 5e69eac..77a42d5 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -7,6 +7,7 @@ const UNTITLED = "Untitled Template"
export function TemplateNameInput() {
const templateName = useEditorStore((s) => s.templateName)
+ const templateId = useEditorStore((s) => s.templateId)
const isPristine = useEditorStore((s) => s.isPristine)
const setTemplateName = useEditorStore((s) => s.setTemplateName)
@@ -26,15 +27,15 @@ export function TemplateNameInput() {
}
}, [templateName])
- // Focus the input whenever a new template is created (isPristine transitions
- // false → true, which only happens via resetToNewTemplate).
+ // Focus the input when starting a new unsaved template (reset → pristine with no id).
+ // Do not focus when transitioning to pristine after a successful save (id stays set).
useEffect(() => {
const wasPristine = prevIsPristineRef.current
prevIsPristineRef.current = isPristine
- if (isPristine && !wasPristine) {
+ if (isPristine && !wasPristine && templateId === null) {
inputRef.current?.focus()
}
- }, [isPristine])
+ }, [isPristine, templateId])
const commit = () => {
const trimmed = localValueRef.current.trim()
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 0e629bd..f124b0b 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -63,7 +63,7 @@ export function TemplateStatus() {
}
}, [syncStatus])
- if (!provider || isPristine) return null
+ if (!provider || (isPristine && syncStatus !== "error")) return null
const providerName = provider.getProviderName()
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index cd19017..8cf8850 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -34,6 +34,7 @@ import { PiSquaresFourLight } from "@react-icons/all-files/pi/PiSquaresFourLight
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import type { TemplateRecord } from "../../storage"
+import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
const MAX_VISIBLE = 5
@@ -65,6 +66,9 @@ export function TemplatesDropdown({
const syncStatus = useEditorStore((s) => s.syncStatus)
const isPristine = useEditorStore((s) => s.isPristine)
const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
+ const templateStorageWriteBlocked = useEditorStore(
+ (s) => s.templateStorageWriteBlocked,
+ )
const fetchTemplates = useCallback(async () => {
if (!provider) return
@@ -103,13 +107,23 @@ export function TemplatesDropdown({
const filtered = useMemo(() => {
const base = templates
.filter((t) => t.id !== templateId)
+ .filter((t) => {
+ if (
+ shouldShowCurrent &&
+ templateId === null &&
+ t.name === templateName
+ ) {
+ return false
+ }
+ return true
+ })
.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
// Sort by: pinned first, then by most recently used/updated
return [...base]
.sort((a, b) => {
- const aPinned = a.pinnedBy.includes("local") ? 1 : 0
- const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ const aPinned = a.isPinned ? 1 : 0
+ const bPinned = b.isPinned ? 1 : 0
if (aPinned !== bPinned) {
return bPinned - aPinned
}
@@ -119,7 +133,7 @@ export function TemplatesDropdown({
return bTime - aTime
})
.slice(0, MAX_VISIBLE)
- }, [templates, templateId, search])
+ }, [templates, templateId, search, shouldShowCurrent, templateName])
if (!provider) return null
@@ -147,39 +161,34 @@ export function TemplatesDropdown({
const handleTogglePin = async (record: TemplateRecord) => {
if (!provider) return
- const currentUserId = "local"
- const isPinned = record.pinnedBy.includes(currentUserId)
- const nextPinnedBy = isPinned
- ? record.pinnedBy.filter((id) => id !== currentUserId)
- : [...record.pinnedBy, currentUserId]
try {
setPinningId(record.id)
- const updated = await provider.saveTemplate({
- id: record.id,
- name: record.name,
- transformations: record.transformations,
- clientNumber: record.clientNumber,
- isPrivate: record.isPrivate,
- pinnedBy: nextPinnedBy,
- createdBy: record.createdBy,
- updatedBy: record.updatedBy,
- createdAt: record.createdAt,
- updatedAt: record.updatedAt,
- })
+ const updated = await provider.setTemplatePinned(
+ record.id,
+ !record.isPinned,
+ )
setTemplates((prev) =>
prev.map((t) => (t.id === updated.id ? updated : t)),
)
- } catch {
- // ignore pin failures in dropdown
+ } catch (err) {
+ const { denyTemplateStorageAccess } = useEditorStore.getState()
+ if (
+ record.id === templateId &&
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
} finally {
setPinningId((current) => (current === record.id ? null : current))
}
}
const handleSaveAndContinue = async () => {
- if (!provider || !pendingTemplate) return
+ if (!provider || !pendingTemplate || templateStorageWriteBlocked) return
const state = useEditorStore.getState()
setSyncStatus("saving")
try {
@@ -191,13 +200,22 @@ export function TemplatesDropdown({
),
})
setSyncStatus("saved")
+ doLoadTemplate(pendingTemplate)
} catch (err) {
+ const { denyTemplateStorageAccess, setSyncStatus } =
+ useEditorStore.getState()
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setSyncStatus(
"error",
err instanceof Error ? err.message : "Failed to save",
)
}
- doLoadTemplate(pendingTemplate)
}
const handleContinueWithoutSaving = () => {
@@ -428,7 +446,7 @@ export function TemplatesDropdown({
) : (
{
+ .catch((err) => {
if (cancelled) return
+ const { denyTemplateStorageAccess } = useEditorStore.getState()
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setIsPrivate(null)
})
@@ -109,8 +118,16 @@ export const Header = ({
if (cancelled) return
setIsPrivate(record ? record.isPrivate : null)
})
- .catch(() => {
+ .catch((err) => {
if (cancelled) return
+ const { denyTemplateStorageAccess } = useEditorStore.getState()
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setIsPrivate(null)
})
@@ -204,7 +221,11 @@ export const Header = ({
}}
/>
) : (
-
)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index b91a449..f0a5a22 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -23,6 +23,7 @@ import {
import { BsThreeDots } from "@react-icons/all-files/bs/BsThreeDots"
import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
+import { PiGear } from "@react-icons/all-files/pi/PiGear"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass"
@@ -38,6 +39,7 @@ import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
+import { SettingsModal } from "../header/SettingsModal"
interface Props {
onClose(): void
@@ -62,10 +64,15 @@ export function TemplatesLibraryView({ onClose }: Props) {
const [visibilityFilter, setVisibilityFilter] = useState([])
const [creatorFilter, setCreatorFilter] = useState([])
const [pinningId, setPinningId] = useState(null)
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
+ const [settingsKnownIsPrivate, setSettingsKnownIsPrivate] = useState<
+ boolean | null
+ >(null)
const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
+ const templateName = useEditorStore((s) => s.templateName)
const isPristine = useEditorStore((s) => s.isPristine)
const syncStatus = useEditorStore((s) => s.syncStatus)
@@ -110,6 +117,16 @@ export function TemplatesLibraryView({ onClose }: Props) {
const filtered = useMemo(() => {
const base = templates
.filter((t) => t.id !== templateId)
+ .filter((t) => {
+ if (
+ shouldShowCurrent &&
+ templateId === null &&
+ t.name === templateName
+ ) {
+ return false
+ }
+ return true
+ })
.filter((t) =>
search
? t.name.toLowerCase().includes(search.toLowerCase()) ||
@@ -132,8 +149,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
// Sort so that pinned templates (for the local user) come first,
// then all others by most recently used / updated.
return [...base].sort((a, b) => {
- const aPinned = a.pinnedBy.includes("local") ? 1 : 0
- const bPinned = b.pinnedBy.includes("local") ? 1 : 0
+ const aPinned = a.isPinned ? 1 : 0
+ const bPinned = b.isPinned ? 1 : 0
if (aPinned !== bPinned) {
return bPinned - aPinned
}
@@ -142,7 +159,15 @@ export function TemplatesLibraryView({ onClose }: Props) {
const bTime = b.lastUsedAt ?? b.updatedAt
return bTime - aTime
})
- }, [templates, templateId, search, visibilityFilter, creatorFilter])
+ }, [
+ templates,
+ templateId,
+ templateName,
+ search,
+ visibilityFilter,
+ creatorFilter,
+ shouldShowCurrent,
+ ])
const handleSelect = (record: TemplateRecord) => {
if (isPristine || syncStatus === "saved") {
@@ -156,27 +181,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
const handleTogglePin = async (record: TemplateRecord) => {
if (!provider) return
- // For the local storage provider we only have a single logical user.
- const currentUserId = "local"
- const isPinned = record.pinnedBy.includes(currentUserId)
- const nextPinnedBy = isPinned
- ? record.pinnedBy.filter((id) => id !== currentUserId)
- : [...record.pinnedBy, currentUserId]
-
try {
setPinningId(record.id)
- const updated = await provider.saveTemplate({
- id: record.id,
- name: record.name,
- transformations: record.transformations,
- clientNumber: record.clientNumber,
- isPrivate: record.isPrivate,
- pinnedBy: nextPinnedBy,
- createdBy: record.createdBy,
- updatedBy: record.updatedBy,
- createdAt: record.createdAt,
- updatedAt: record.updatedAt,
- })
+ const updated = await provider.setTemplatePinned(
+ record.id,
+ !record.isPinned,
+ )
setTemplates((prev) =>
prev.map((t) => (t.id === updated.id ? updated : t)),
@@ -188,6 +198,32 @@ export function TemplatesLibraryView({ onClose }: Props) {
}
}
+ const handleDeleteTemplate = useCallback(
+ async (record: TemplateRecord) => {
+ if (!provider) return
+ if (!provider.deleteTemplate) return
+
+ await provider.deleteTemplate(record.id)
+ setTemplates((prev) => prev.filter((t) => t.id !== record.id))
+
+ if (record.id === useEditorStore.getState().templateId) {
+ resetToNewTemplate()
+ }
+ },
+ [provider, resetToNewTemplate],
+ )
+
+ const handleOpenSettings = useCallback(
+ (record: TemplateRecord) => {
+ loadTemplate(record.transformations)
+ setTemplateName(record.name)
+ setTemplateId(record.id)
+ setSettingsKnownIsPrivate(record.isPrivate)
+ setIsSettingsOpen(true)
+ },
+ [loadTemplate, setTemplateId, setTemplateName],
+ )
+
return (
{
- // Deletion for current row is disabled via props.
- }}
+ onDelete={handleDeleteTemplate}
+ onSettings={handleOpenSettings}
isCurrent
- canDelete={false}
+ canDelete
/>
)}
@@ -481,12 +516,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
onSelect={handleSelect}
onTogglePin={handleTogglePin}
isPinning={pinningId === record.id}
- onDelete={async (r) => {
- if (!provider) return
- if (!provider.deleteTemplate) return
- await provider.deleteTemplate(r.id)
- setTemplates((prev) => prev.filter((t) => t.id !== r.id))
- }}
+ onDelete={handleDeleteTemplate}
+ onSettings={handleOpenSettings}
/>
))
)}
@@ -494,6 +525,13 @@ export function TemplatesLibraryView({ onClose }: Props) {
)}
+ {isSettingsOpen && (
+ setIsSettingsOpen(false)}
+ />
+ )}
)
}
@@ -503,6 +541,7 @@ interface TemplateRowProps {
onSelect(record: TemplateRecord): void
onTogglePin(record: TemplateRecord): void
onDelete(record: TemplateRecord): void
+ onSettings(record: TemplateRecord): void
isPinning: boolean
isCurrent?: boolean
canDelete?: boolean
@@ -513,6 +552,7 @@ function TemplateRow({
onSelect,
onTogglePin,
onDelete,
+ onSettings,
isPinning,
isCurrent = false,
canDelete = true,
@@ -547,12 +587,10 @@ function TemplateRow({
) : (
)}
@@ -681,6 +719,15 @@ function TemplateRow({
borderColor="transparent"
onClick={(e) => e.stopPropagation()}
>
+ }
+ onClick={(e) => {
+ e.stopPropagation()
+ onSettings(record)
+ }}
+ >
+ Settings
+
}
color={canDelete ? "red.500" : "gray.400"}
diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
index 3d0f36e..c0e1692 100644
--- a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
+++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"
import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { applyTemplateStorageAccessFailure } from "../storage/templateAccessError"
import { useEditorStore } from "../store"
const DEBOUNCE_MS = 600
@@ -47,9 +48,10 @@ export function useAutoSaveTemplate() {
// Re-read fresh state at fire time: the slice snapshot can be up to
// DEBOUNCE_MS stale by the time the timer fires.
const state = useEditorStore.getState()
- if (state.isPristine) return
+ if (state.isPristine || state.templateStorageWriteBlocked) return
- const { setSyncStatus, setTemplateId } = state
+ const { setSyncStatus, setTemplateId, denyTemplateStorageAccess } =
+ state
setSyncStatus("saving")
try {
const saved = await provider.saveTemplate({
@@ -62,6 +64,13 @@ export function useAutoSaveTemplate() {
setTemplateId(saved.id)
setSyncStatus("saved")
} catch (err) {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setSyncStatus(
"error",
err instanceof Error
diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
index de03ed2..8ee2470 100644
--- a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
+++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts
@@ -1,15 +1,23 @@
import { useCallback, useEffect } from "react"
import { useTemplateStorage } from "../context/TemplateStorageContext"
+import { applyTemplateStorageAccessFailure } from "../storage/templateAccessError"
import { useEditorStore } from "../store"
export function useSaveTemplate() {
const provider = useTemplateStorage()
- const { setSyncStatus, setTemplateId, setTemplateName } = useEditorStore()
+ const {
+ setSyncStatus,
+ setTemplateId,
+ setTemplateName,
+ denyTemplateStorageAccess,
+ } = useEditorStore()
const save = useCallback(async () => {
if (!provider) return
const state = useEditorStore.getState()
+ if (state.templateStorageWriteBlocked) return
+
const { transformations, templateName, templateId } = state
setSyncStatus("saving")
@@ -23,12 +31,25 @@ export function useSaveTemplate() {
setTemplateName(saved.name)
setSyncStatus("saved")
} catch (err) {
+ if (
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccess,
+ })
+ ) {
+ return
+ }
setSyncStatus(
"error",
err instanceof Error ? err.message : "Failed to save template",
)
}
- }, [provider, setSyncStatus, setTemplateId, setTemplateName])
+ }, [
+ provider,
+ setSyncStatus,
+ setTemplateId,
+ setTemplateName,
+ denyTemplateStorageAccess,
+ ])
useEffect(() => {
if (!provider) return
diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx
index fb34fbd..ac2242e 100644
--- a/packages/imagekit-editor-dev/src/index.tsx
+++ b/packages/imagekit-editor-dev/src/index.tsx
@@ -3,9 +3,17 @@ export { ImageKitEditor } from "./ImageKitEditor"
export { DEFAULT_FOCUS_OBJECTS } from "./schema"
export type {
LocalStorageProviderOptions,
+ SaveTemplateInput,
TemplateRecord,
+ TemplateStorageHttpClient,
TemplateStorageProvider,
} from "./storage"
-export { createLocalStorageProvider } from "./storage"
+export {
+ applyTemplateStorageAccessFailure,
+ createLocalStorageProvider,
+ isTemplateAccessDeniedError,
+ normalizeTransformationStepsForPersistence,
+ TemplateAccessDeniedError,
+} from "./storage"
export type { FileElement, Signer, Transformation } from "./store"
export { TRANSFORMATION_STATE_VERSION } from "./store"
diff --git a/packages/imagekit-editor-dev/src/storage/index.ts b/packages/imagekit-editor-dev/src/storage/index.ts
index fbfd074..62ec879 100644
--- a/packages/imagekit-editor-dev/src/storage/index.ts
+++ b/packages/imagekit-editor-dev/src/storage/index.ts
@@ -1,8 +1,15 @@
export { createLocalStorageProvider } from "./localStorage-provider"
+export { normalizeTransformationStepsForPersistence } from "./serializeTransformations"
+export {
+ applyTemplateStorageAccessFailure,
+ isTemplateAccessDeniedError,
+ TemplateAccessDeniedError,
+} from "./templateAccessError"
export type {
LocalStorageProviderOptions,
SaveTemplateInput,
TemplateCreator,
TemplateRecord,
+ TemplateStorageHttpClient,
TemplateStorageProvider,
} from "./types"
diff --git a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
index f40cad9..8b3932a 100644
--- a/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
+++ b/packages/imagekit-editor-dev/src/storage/localStorage-provider.ts
@@ -1,3 +1,4 @@
+import { normalizeTransformationStepsForPersistence } from "./serializeTransformations"
import type {
LocalStorageProviderOptions,
SaveTemplateInput,
@@ -24,7 +25,7 @@ function normalizeRecord(raw: Record): TemplateRecord {
name: (raw.name as string) || "",
transformations:
(raw.transformations as TemplateRecord["transformations"]) || [],
- pinnedBy: (raw.pinnedBy as string[]) || [],
+ isPinned: typeof raw.isPinned === "boolean" ? raw.isPinned : false,
createdBy: (raw.createdBy as TemplateCreator) || LOCAL_USER,
updatedBy: (raw.updatedBy as TemplateCreator) || LOCAL_USER,
createdAt: (raw.createdAt as number) || updatedAt,
@@ -76,6 +77,9 @@ export function createLocalStorageProvider(
await new Promise((resolve) => setTimeout(resolve, 1500))
const templates = readTemplates()
const now = Date.now()
+ const transformations = normalizeTransformationStepsForPersistence(
+ record.transformations,
+ )
if (record.id) {
const index = templates.findIndex((t) => t.id === record.id)
@@ -84,9 +88,9 @@ export function createLocalStorageProvider(
const updated: TemplateRecord = {
...existing,
name: record.name,
- transformations: record.transformations,
+ transformations,
isPrivate: record.isPrivate ?? existing.isPrivate,
- pinnedBy: record.pinnedBy ?? existing.pinnedBy,
+ isPinned: record.isPinned ?? existing.isPinned,
updatedAt: record.updatedAt ?? now,
updatedBy: record.updatedBy ?? LOCAL_USER,
}
@@ -101,8 +105,8 @@ export function createLocalStorageProvider(
clientNumber: record.clientNumber ?? "local",
isPrivate: record.isPrivate ?? true,
name: record.name,
- transformations: record.transformations,
- pinnedBy: record.pinnedBy ?? [],
+ transformations,
+ isPinned: record.isPinned ?? false,
createdBy: record.createdBy ?? LOCAL_USER,
updatedBy: record.updatedBy ?? record.createdBy ?? LOCAL_USER,
createdAt: record.createdAt ?? now,
@@ -117,5 +121,26 @@ export function createLocalStorageProvider(
const templates = readTemplates().filter((t) => t.id !== id)
writeTemplates(templates)
},
+
+ async setTemplatePinned(
+ id: string,
+ isPinned: boolean,
+ ): Promise {
+ const templates = readTemplates()
+ const index = templates.findIndex((t) => t.id === id)
+ if (index === -1) {
+ throw new Error("Template not found")
+ }
+ const existing = templates[index]
+ const updated: TemplateRecord = {
+ ...existing,
+ isPinned,
+ updatedAt: Date.now(),
+ updatedBy: LOCAL_USER,
+ }
+ templates[index] = updated
+ writeTemplates(templates)
+ return updated
+ },
}
}
diff --git a/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts b/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts
new file mode 100644
index 0000000..62fff6a
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/serializeTransformations.ts
@@ -0,0 +1,12 @@
+import { TRANSFORMATION_STATE_VERSION } from "../store"
+import type { SaveTemplateInput } from "./types"
+
+/** Ensures each step has `version: "v1"` for API / persistence validators. */
+export function normalizeTransformationStepsForPersistence(
+ transformations: SaveTemplateInput["transformations"],
+): SaveTemplateInput["transformations"] {
+ return transformations.map((step) => ({
+ ...step,
+ version: step.version ?? TRANSFORMATION_STATE_VERSION,
+ }))
+}
diff --git a/packages/imagekit-editor-dev/src/storage/templateAccessError.ts b/packages/imagekit-editor-dev/src/storage/templateAccessError.ts
new file mode 100644
index 0000000..ddcced5
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/storage/templateAccessError.ts
@@ -0,0 +1,48 @@
+/**
+ * Thrown by host {@link TemplateStorageProvider} implementations when the
+ * backend responds with 401/403 (e.g. template made private or access revoked).
+ * Dashboard / API clients should `throw new TemplateAccessDeniedError(...)` or
+ * attach `status: 401 | 403` to the rejected error so the editor can reset UI.
+ */
+export class TemplateAccessDeniedError extends Error {
+ readonly status: number
+
+ constructor(message?: string, status: number = 403) {
+ super(message ?? "You no longer have access to this template.")
+ this.name = "TemplateAccessDeniedError"
+ this.status = status
+ }
+}
+
+export function isTemplateAccessDeniedError(err: unknown): boolean {
+ if (err instanceof TemplateAccessDeniedError) {
+ return true
+ }
+ if (err && typeof err === "object" && "status" in err) {
+ const s = (err as { status: unknown }).status
+ if (s === 401 || s === 403) {
+ return true
+ }
+ }
+ return false
+}
+
+export type TemplateStorageFailureActions = {
+ denyTemplateStorageAccess: (message?: string) => void
+}
+
+/** Clears the loaded template and surfaces an error when access was revoked. */
+export function applyTemplateStorageAccessFailure(
+ err: unknown,
+ actions: TemplateStorageFailureActions,
+): boolean {
+ if (!isTemplateAccessDeniedError(err)) {
+ return false
+ }
+ const message =
+ err instanceof TemplateAccessDeniedError
+ ? err.message
+ : "You no longer have access to this template."
+ actions.denyTemplateStorageAccess(message)
+ return true
+}
diff --git a/packages/imagekit-editor-dev/src/storage/types.ts b/packages/imagekit-editor-dev/src/storage/types.ts
index ab9fc02..1a18f1a 100644
--- a/packages/imagekit-editor-dev/src/storage/types.ts
+++ b/packages/imagekit-editor-dev/src/storage/types.ts
@@ -12,7 +12,8 @@ export interface TemplateRecord {
isPrivate: boolean
name: string
transformations: Omit[]
- pinnedBy: string[]
+ /** Whether the active user has this template pinned. */
+ isPinned: boolean
createdBy: TemplateCreator
updatedBy: TemplateCreator
createdAt: number
@@ -26,7 +27,7 @@ export type SaveTemplateInput = {
transformations: Omit[]
clientNumber?: string
isPrivate?: boolean
- pinnedBy?: string[]
+ isPinned?: boolean
createdBy?: TemplateCreator
updatedBy?: TemplateCreator
createdAt?: number
@@ -42,9 +43,22 @@ export interface TemplateStorageProvider {
getTemplate(id: string): Promise
saveTemplate(record: SaveTemplateInput): Promise
deleteTemplate?(id: string): Promise
+ setTemplatePinned(id: string, isPinned: boolean): Promise
getProviderName(): string
+ getCurrentUserSession(): unknown
}
export interface LocalStorageProviderOptions {
templatesKey?: string
}
+
+/**
+ * Minimal HTTP surface for host-implemented template storage (e.g. dashboard `use-http` request).
+ */
+export interface TemplateStorageHttpClient {
+ get(path: string): Promise
+ post(path: string, body?: unknown): Promise
+ patch(path: string, body?: unknown): Promise
+ put(path: string, body?: unknown): Promise
+ delete(path: string): Promise
+}
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index ac306d6..41433f7 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -97,6 +97,11 @@ export interface EditorState<
syncStatus: SyncStatus
storageError?: string
isPristine: boolean
+ /**
+ * After a 401/403 template write failure, saves are blocked so a follow-up
+ * save cannot POST a duplicate after the store clears `templateId`.
+ */
+ templateStorageWriteBlocked: boolean
}
export type EditorActions<
@@ -137,7 +142,9 @@ export type EditorActions<
setTemplateName: (name: string) => void
setTemplateId: (id: string | null) => void
setSyncStatus: (status: SyncStatus, error?: string) => void
+ setIsPristine: (pristine: boolean) => void
resetToNewTemplate: () => void
+ denyTemplateStorageAccess: (message?: string) => void
_setSidebarState: (state: "none" | "type" | "config") => void
_setSelectedTransformationKey: (key: string | null) => void
@@ -204,6 +211,7 @@ const DEFAULT_STATE: EditorState = {
syncStatus: "unsaved",
storageError: undefined,
isPristine: true,
+ templateStorageWriteBlocked: false,
}
const useEditorStore = create()(
@@ -358,6 +366,7 @@ const useEditorStore = create()(
transformationToEdit: null,
},
isPristine: false,
+ templateStorageWriteBlocked: false,
}))
},
@@ -481,6 +490,10 @@ const useEditorStore = create()(
set({ syncStatus: status, storageError: error })
},
+ setIsPristine: (pristine: boolean) => {
+ set({ isPristine: pristine })
+ },
+
resetToNewTemplate: () => {
set({
transformations: [],
@@ -490,6 +503,25 @@ const useEditorStore = create()(
syncStatus: "unsaved",
storageError: undefined,
isPristine: true,
+ templateStorageWriteBlocked: false,
+ _internalState: {
+ sidebarState: "none",
+ selectedTransformationKey: null,
+ transformationToEdit: null,
+ },
+ })
+ },
+
+ denyTemplateStorageAccess: (message) => {
+ set({
+ transformations: [],
+ visibleTransformations: {},
+ templateName: "Untitled Template",
+ templateId: null,
+ syncStatus: "error",
+ storageError: message ?? "You no longer have access to this template.",
+ isPristine: true,
+ templateStorageWriteBlocked: true,
_internalState: {
sidebarState: "none",
selectedTransformationKey: null,
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 84f66d6..ed7c817 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0",
+ "version": "2.2.0-dev.1",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 39d0e2c8b2e516a741021c2d935d2b0633caefa3 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 9 Apr 2026 13:27:50 +0530
Subject: [PATCH 32/64] fix: regression in template dropdown showing creator
avatar and pin onHover
---
.../src/components/header/TemplatesDropdown.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 8cf8850..0abf6ef 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -384,6 +384,7 @@ export function TemplatesDropdown({
) : (
filtered.map((record) => (
+ // biome-ignore lint/a11y/useSemanticElements: Not necessary for this component
handleSelect(record)}
transition="background-color 0.15s"
From f743d6e2170f1acc2eae56b296ac9d9c937d56aa Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 9 Apr 2026 13:29:38 +0530
Subject: [PATCH 33/64] fix: restore template dropdown hover
Made-with: Cursor
---
image-server-dashboard-ref | 1 +
1 file changed, 1 insertion(+)
create mode 120000 image-server-dashboard-ref
diff --git a/image-server-dashboard-ref b/image-server-dashboard-ref
new file mode 120000
index 0000000..d3e04a3
--- /dev/null
+++ b/image-server-dashboard-ref
@@ -0,0 +1 @@
+/Users/harshit/Code/image-server-dashboard
\ No newline at end of file
From 458cc5e6f3ba430952c816d2dcc1c1f48c66526d Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 9 Apr 2026 13:36:50 +0530
Subject: [PATCH 34/64] fix: contextual awareness for close confirmation modal
---
packages/imagekit-editor-dev/src/ImageKitEditor.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index 9c04d40..3af68d4 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -148,7 +148,13 @@ function ImageKitEditorImpl(
}, [resolvedProvider])
const handleOnClose = () => {
- const dirty = transformations.length > 0
+ // `dirty` should represent *unsynced* changes (host uses it to decide
+ // whether to show a close confirmation).
+ const state = useEditorStore.getState()
+ const hasChanges = !state.isPristine
+ const dirty = resolvedProvider
+ ? hasChanges && state.syncStatus !== "saved"
+ : hasChanges
props.onClose({ dirty, destroy })
}
From f0727c95f764bef563b034230fcbc7eb86e7b90f Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 9 Apr 2026 13:37:55 +0530
Subject: [PATCH 35/64] chore: remove accidentally commited symlink
---
image-server-dashboard-ref | 1 -
1 file changed, 1 deletion(-)
delete mode 120000 image-server-dashboard-ref
diff --git a/image-server-dashboard-ref b/image-server-dashboard-ref
deleted file mode 120000
index d3e04a3..0000000
--- a/image-server-dashboard-ref
+++ /dev/null
@@ -1 +0,0 @@
-/Users/harshit/Code/image-server-dashboard
\ No newline at end of file
From 6a19cf6de596787e64cafc1fd8ea60174c39879a Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Fri, 10 Apr 2026 10:49:33 +0530
Subject: [PATCH 36/64] fix: contextual awareness for isDirty and save status
of template for apply/close buttons and close editor flows
---
.../src/components/header/SettingsModal.tsx | 25 +-
.../sidebar/transformation-config-sidebar.tsx | 374 ++++++++++++++----
.../src/hooks/useAutoSaveTemplate.ts | 95 +++--
packages/imagekit-editor-dev/src/store.ts | 14 +-
4 files changed, 383 insertions(+), 125 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 9fcbbf2..98477d3 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -63,6 +63,7 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
const [canChangeVisibility, setCanChangeVisibility] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
+ const prevVisibilityRef = useRef(localVisibility)
useEffect(() => {
setLocalName(templateName)
@@ -116,7 +117,7 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
// onClose is intentionally excluded; we use onCloseRef instead.
}, [provider, templateId, denyTemplateStorageAccess])
- const handleSave = async () => {
+ const saveTemplate = async (opts?: { closeAfter?: boolean }) => {
if (!provider || !localName.trim() || templateStorageWriteBlocked) return
setIsSaving(true)
@@ -133,14 +134,18 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
setTemplateId(saved.id)
setTemplateName(localName.trim())
setSyncStatus("saved")
- onClose()
+ if (opts?.closeAfter !== false) {
+ onClose()
+ }
} catch (err) {
if (
applyTemplateStorageAccessFailure(err, {
denyTemplateStorageAccess,
})
) {
- onClose()
+ if (opts?.closeAfter !== false) {
+ onClose()
+ }
return
}
setSyncStatus(
@@ -152,6 +157,18 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
}
}
+ // Auto-save visibility changes (instant, like template name).
+ useEffect(() => {
+ if (!provider || !templateId) return
+ if (!canChangeVisibility) return
+ if (isSaving || isDeleting || templateStorageWriteBlocked) return
+ const prev = prevVisibilityRef.current
+ if (prev === localVisibility) return
+ prevVisibilityRef.current = localVisibility
+ void saveTemplate({ closeAfter: false })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [localVisibility])
+
const handleDelete = async () => {
if (!provider || !templateId) return
@@ -355,7 +372,7 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
+
{/* Content */}
@@ -296,6 +304,7 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
if (!canChangeVisibility) return
if (option) {
setLocalVisibility(option.value as "everyone" | "onlyMe")
+ setTemplateIsPrivate(option.value === "onlyMe")
}
}}
options={[
@@ -312,26 +321,7 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
{data.label}
)}
- styles={{
- control: (base) => ({
- ...base,
- fontSize: "12px",
- minHeight: "32px",
- borderColor: "#E2E8F0",
- backgroundColor: canChangeVisibility
- ? base.backgroundColor
- : "#F7FAFC",
- opacity: canChangeVisibility ? 1 : 0.6,
- }),
- menu: (base) => ({
- ...base,
- zIndex: 10,
- }),
- option: (base) => ({
- ...base,
- fontSize: "12px",
- }),
- }}
+ styles={selectStyles}
isSearchable={false}
isDisabled={!canChangeVisibility}
/>
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
new file mode 100644
index 0000000..2c068bc
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
@@ -0,0 +1,595 @@
+import { ChakraProvider } from "@chakra-ui/react"
+import { act, fireEvent, render, screen } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext"
+import {
+ INTERVAL_SAVE_MS,
+ useAutoSaveTemplate,
+} from "../../hooks/useAutoSaveTemplate"
+import { useEditorStore } from "../../store"
+import { TransformationConfigSidebar } from "../sidebar/transformation-config-sidebar"
+import { TemplateStatus } from "./TemplateStatus"
+
+function AutoSaveHarness() {
+ useAutoSaveTemplate()
+ return null
+}
+
+function renderWithProvider(
+ providerOverrides?: Partial>,
+) {
+ const provider = {
+ getProviderName: () => "library",
+ getCurrentUserSession: () => ({}),
+ listTemplates: async () => [],
+ getTemplate: async () => null,
+ // biome-ignore lint/suspicious/noExplicitAny: test stub
+ saveTemplate: async (r: any) => ({
+ id: "t1",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: r.name ?? "n",
+ transformations: r.transformations ?? [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "U", email: "u@example.com" },
+ updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }),
+ setTemplatePinned: async () => {
+ throw new Error("not used")
+ },
+ ...(providerOverrides ?? {}),
+ }
+
+ return render(
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test stub */}
+
+
+
+
+ ,
+ )
+}
+
+describe("TemplateStatus", () => {
+ beforeEach(() => {
+ useEditorStore.getState().destroy()
+ vi.useFakeTimers()
+ })
+
+ function seedSyncedState(
+ partial?: Partial[0]>,
+ ) {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ ...(partial ?? {}),
+ } as unknown as Parameters[0])
+ }
+
+ function expectStartsSaved() {
+ // Let any initial notification settle so we're truly in the persistent icon state.
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.getByLabelText("template-status-saved")).toBeTruthy()
+ }
+
+ function expectStaysUnsavedAfterDelay(ms: number) {
+ act(() => {
+ vi.advanceTimersByTime(ms)
+ })
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expect(screen.queryByLabelText("template-status-saved")).toBeNull()
+ }
+
+ it("shows unsaved local changes when there are unsynced edits even if syncStatus is saved", () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 2,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider()
+ expect(screen.getByText("Unsaved local changes")).toBeTruthy()
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("shows unsaved when transformation config form has unapplied edits even if versions are synced", () => {
+ useEditorStore.setState({
+ isPristine: true,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ transformationConfigFormDirty: true,
+ } as unknown as Parameters[0])
+
+ renderWithProvider()
+ expect(screen.getByText("Unsaved local changes")).toBeTruthy()
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("does not show the saved text while unsynced changes exist", () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 2,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider()
+ expect(screen.queryByText(/Saved to library/i)).toBeNull()
+ expect(screen.queryByLabelText("template-status-saved")).toBeNull()
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("after Apply, stays unsynced until manual save or INTERVAL_SAVE_MS elapses", async () => {
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider signature
+ const saveTemplate = vi.fn(async (r: any) => ({
+ id: "t1",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: r.name ?? "n",
+ transformations: r.transformations ?? [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "U", email: "u@example.com" },
+ updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }))
+
+ // Start in a fully synced "saved" state.
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider({ saveTemplate })
+
+ // Simulate "Apply": committed store change but not saved.
+ act(() => {
+ useEditorStore.getState().addTransformation({
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: value type is IKTransformation; test doesn't need full typing
+ value: { contrast: true } as any,
+ enabled: true,
+ // biome-ignore lint/suspicious/noExplicitAny: test doesn't care about store-side version stamping
+ version: "v1" as any,
+ // biome-ignore lint/suspicious/noExplicitAny: store action accepts partial transform in test
+ } as any)
+ })
+
+ // After the 3s notification fades, it must STILL be unsynced (not green).
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expect(screen.queryByLabelText("template-status-saved")).toBeNull()
+
+ // No save should happen before the interval.
+ const remainingBeforeInterval = Math.max(0, INTERVAL_SAVE_MS - 3500 - 1)
+ act(() => {
+ vi.advanceTimersByTime(remainingBeforeInterval)
+ })
+ expect(saveTemplate).toHaveBeenCalledTimes(0)
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+
+ // At the interval, the auto-save should run.
+ await act(async () => {
+ vi.advanceTimersByTime(1)
+ await Promise.resolve()
+ })
+ expect(saveTemplate).toHaveBeenCalledTimes(1)
+ })
+
+ it("editing an existing transformation flips status to unsaved immediately (before Apply/Save)", () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ transformations: [
+ {
+ id: "t1",
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { contrast: false } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t1: true },
+ _internalState: {
+ sidebarState: "config",
+ selectedTransformationKey: "adjust-contrast",
+ transformationToEdit: { transformationId: "t1", position: "inplace" },
+ },
+ } as unknown as Parameters[0])
+
+ render(
+
+ "library",
+ getCurrentUserSession: () => ({}),
+ listTemplates: async () => [],
+ getTemplate: async () => null,
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider signature
+ saveTemplate: async (r: any) => ({
+ id: "t1",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: r.name ?? "n",
+ transformations: r.transformations ?? [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "U", email: "u@example.com" },
+ updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }),
+ setTemplatePinned: async () => {
+ throw new Error("not used")
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider typing
+ } as any
+ }
+ >
+
+
+
+ ,
+ )
+
+ // Let the initial "saved" notification settle.
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.getByLabelText("template-status-saved")).toBeTruthy()
+
+ // Toggle the existing transformation switch (should dirty the RHF form).
+ act(() => {
+ screen.getByLabelText("Contrast").click()
+ })
+
+ // Should flip to unsaved immediately (no Apply/Save yet).
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("editing an existing transformation slider field flips status to unsaved immediately", () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ transformations: [
+ {
+ id: "t2",
+ key: "adjust-opacity",
+ name: "Opacity",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { opacity: 50 } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t2: true },
+ _internalState: {
+ sidebarState: "config",
+ selectedTransformationKey: "adjust-opacity",
+ transformationToEdit: { transformationId: "t2", position: "inplace" },
+ },
+ } as unknown as Parameters[0])
+
+ render(
+
+ "library",
+ getCurrentUserSession: () => ({}),
+ listTemplates: async () => [],
+ getTemplate: async () => null,
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider signature
+ saveTemplate: async (r: any) => ({
+ id: "t2",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: r.name ?? "n",
+ transformations: r.transformations ?? [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "U", email: "u@example.com" },
+ updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }),
+ setTemplatePinned: async () => {
+ throw new Error("not used")
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider typing
+ } as any
+ }
+ >
+
+
+
+ ,
+ )
+
+ // Let the initial "saved" notification settle.
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.getByLabelText("template-status-saved")).toBeTruthy()
+
+ const opacityInput = document.getElementById(
+ "opacity-input",
+ ) as HTMLInputElement
+ expect(opacityInput).toBeTruthy()
+
+ act(() => {
+ fireEvent.change(opacityInput, { target: { value: "60" } })
+ })
+
+ // Should flip to unsaved immediately (no Apply/Save yet).
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("editing an existing transformation color picker field flips status to unsaved immediately", () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ transformations: [
+ {
+ id: "t3",
+ key: "adjust-border",
+ name: "Border",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { borderWidth: 1, borderColor: "#000000" } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t3: true },
+ _internalState: {
+ sidebarState: "config",
+ selectedTransformationKey: "adjust-border",
+ transformationToEdit: { transformationId: "t3", position: "inplace" },
+ },
+ } as unknown as Parameters[0])
+
+ render(
+
+ "library",
+ getCurrentUserSession: () => ({}),
+ listTemplates: async () => [],
+ getTemplate: async () => null,
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider signature
+ saveTemplate: async (r: any) => ({
+ id: "t3",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: r.name ?? "n",
+ transformations: r.transformations ?? [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "U", email: "u@example.com" },
+ updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }),
+ setTemplatePinned: async () => {
+ throw new Error("not used")
+ },
+ // biome-ignore lint/suspicious/noExplicitAny: test stub provider typing
+ } as any
+ }
+ >
+
+
+
+ ,
+ )
+
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.getByLabelText("template-status-saved")).toBeTruthy()
+
+ const colorInput = screen.getByPlaceholderText(
+ "#FFFFFF",
+ ) as HTMLInputElement
+ act(() => {
+ fireEvent.change(colorInput, { target: { value: "#FFFFFF" } })
+ })
+
+ // ColorPickerField updates RHF via a debounced effect (500ms).
+ act(() => {
+ vi.advanceTimersByTime(600)
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ })
+
+ it("toggle show/hide transformation flips status to unsaved immediately", () => {
+ seedSyncedState({
+ transformations: [
+ {
+ id: "t1",
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { contrast: false } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t1: true },
+ })
+
+ renderWithProvider()
+ expectStartsSaved()
+
+ act(() => {
+ useEditorStore.getState().toggleTransformationVisibility("t1")
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ // It must stay unsaved after the toast fades and over time (before auto-interval save).
+ expectStaysUnsavedAfterDelay(3500)
+ expectStaysUnsavedAfterDelay(10_000)
+ })
+
+ it("reordering transformations flips status to unsaved immediately", () => {
+ seedSyncedState({
+ transformations: [
+ {
+ id: "t1",
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { contrast: false } as any,
+ enabled: true,
+ version: "v1",
+ },
+ {
+ id: "t2",
+ key: "adjust-opacity",
+ name: "Opacity",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { opacity: 50 } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t1: true, t2: true },
+ })
+
+ renderWithProvider()
+ expectStartsSaved()
+
+ act(() => {
+ // Move t1 over t2 (swap order)
+ useEditorStore.getState().moveTransformation("t1", "t2")
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expectStaysUnsavedAfterDelay(3500)
+ expectStaysUnsavedAfterDelay(10_000)
+ })
+
+ it("adding a transformation flips status to unsaved immediately", () => {
+ seedSyncedState({
+ transformations: [],
+ visibleTransformations: {},
+ })
+
+ renderWithProvider()
+ expectStartsSaved()
+
+ act(() => {
+ useEditorStore.getState().addTransformation({
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { contrast: true } as any,
+ enabled: true,
+ version: "v1",
+ // biome-ignore lint/suspicious/noExplicitAny: store action typing is sufficient for runtime in tests
+ } as any)
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expectStaysUnsavedAfterDelay(3500)
+ expectStaysUnsavedAfterDelay(10_000)
+ })
+
+ it("removing a transformation flips status to unsaved immediately", () => {
+ seedSyncedState({
+ transformations: [
+ {
+ id: "t1",
+ key: "adjust-contrast",
+ name: "Contrast",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { contrast: false } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t1: true },
+ })
+
+ renderWithProvider()
+ expectStartsSaved()
+
+ act(() => {
+ useEditorStore.getState().removeTransformation("t1")
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expectStaysUnsavedAfterDelay(3500)
+ expectStaysUnsavedAfterDelay(10_000)
+ })
+
+ it("applying an edit (updateTransformation) flips status to unsaved immediately", () => {
+ seedSyncedState({
+ transformations: [
+ {
+ id: "t1",
+ key: "adjust-opacity",
+ name: "Opacity",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { opacity: 50 } as any,
+ enabled: true,
+ version: "v1",
+ },
+ ],
+ visibleTransformations: { t1: true },
+ })
+
+ renderWithProvider()
+ expectStartsSaved()
+
+ act(() => {
+ useEditorStore.getState().updateTransformation("t1", {
+ id: "t1",
+ key: "adjust-opacity",
+ name: "Opacity",
+ type: "transformation",
+ // biome-ignore lint/suspicious/noExplicitAny: store expects IKTransformation; test doesn't need full typing
+ value: { opacity: 60 } as any,
+ enabled: true,
+ version: "v1",
+ // biome-ignore lint/suspicious/noExplicitAny: store action typing is sufficient for runtime in tests
+ } as any)
+ })
+
+ expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy()
+ expectStaysUnsavedAfterDelay(3500)
+ expectStaysUnsavedAfterDelay(10_000)
+ })
+})
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index f124b0b..d2c7220 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -13,16 +13,33 @@ import {
import { IoMdCloudDone } from "@react-icons/all-files/io/IoMdCloudDone"
import { MdSync } from "@react-icons/all-files/md/MdSync"
import { MdSyncProblem } from "@react-icons/all-files/md/MdSyncProblem"
+import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useEditorStore } from "../../store"
const NOTIFICATION_DURATION_MS = 3000
+const TextAny = Text as unknown as React.FC>
+const TextAny2 = Text as unknown as React.FC>
+const FlexAny = Flex as unknown as React.FC>
+const PopoverContentAny = PopoverContent as unknown as React.FC<
+ Record
+>
+const TooltipAny = Tooltip as unknown as React.FC>
+const PopoverBodyAny = PopoverBody as unknown as React.FC<
+ Record
+>
+
export function TemplateStatus() {
const syncStatus = useEditorStore((s) => s.syncStatus)
const storageError = useEditorStore((s) => s.storageError)
const isPristine = useEditorStore((s) => s.isPristine)
+ const hasPendingLocalWork = useEditorStore(
+ (s) =>
+ s.localChangeVersion !== s.lastSyncedVersion ||
+ s.transformationConfigFormDirty,
+ )
const provider = useTemplateStorage()
const [notificationVisible, setNotificationVisible] = useState(false)
@@ -43,7 +60,7 @@ export function TemplateStatus() {
() => setNotificationVisible(false),
NOTIFICATION_DURATION_MS,
)
- } else if (syncStatus === "unsaved") {
+ } else if (syncStatus === "unsaved" || hasPendingLocalWork) {
setNotificationVisible(true)
timerRef.current = setTimeout(
() => setNotificationVisible(false),
@@ -61,23 +78,27 @@ export function TemplateStatus() {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
- }, [syncStatus])
+ }, [syncStatus, hasPendingLocalWork])
- if (!provider || (isPristine && syncStatus !== "error")) return null
+ if (
+ !provider ||
+ (isPristine && syncStatus !== "error" && !hasPendingLocalWork)
+ )
+ return null
const providerName = provider.getProviderName()
// "Saving…" is a transient text-only state — no icon yet
if (notificationVisible && syncStatus === "saving") {
return (
-
Saving…
-
+
)
}
@@ -89,28 +110,51 @@ export function TemplateStatus() {
let activeColor: string
let notifText: string | null = null
let isInteractive = false
+ let iconAriaLabel:
+ | "template-status-unsaved"
+ | "template-status-saved"
+ | "template-status-error"
if (notificationVisible) {
- if (syncStatus === "saved") {
- activeIcon = IoMdCloudDone
- activeColor = "green.500"
- notifText = `Saved to ${providerName}`
- } else if (syncStatus === "unsaved") {
+ // Unsynced edits take precedence over the last successful save result.
+ // This prevents showing the green cloud when newer changes exist locally.
+ if (hasPendingLocalWork || syncStatus === "unsaved") {
activeIcon = MdSync
activeColor = "editorBattleshipGrey.500"
notifText = "Unsaved local changes"
+ iconAriaLabel = "template-status-unsaved"
+ } else if (syncStatus === "saved") {
+ activeIcon = IoMdCloudDone
+ activeColor = "green.500"
+ notifText = `Saved to ${providerName}`
+ iconAriaLabel = "template-status-saved"
} else if (syncStatus === "error") {
activeIcon = MdSyncProblem
activeColor = "yellow.600"
notifText = "Save failed"
+ iconAriaLabel = "template-status-error"
} else {
return null
}
} else {
- if (lastSyncResult === null) return null
- activeIcon = lastSyncResult === "success" ? IoMdCloudDone : MdSyncProblem
- activeColor = lastSyncResult === "success" ? "green.500" : "yellow.600"
- isInteractive = true
+ // Persistent (icon-only) state:
+ // - If there are unsynced local edits, ALWAYS show the unsaved icon until the next successful save syncs versions.
+ // - Otherwise, show the last save outcome icon (success/error).
+ if (hasPendingLocalWork) {
+ activeIcon = MdSync
+ activeColor = "editorBattleshipGrey.500"
+ isInteractive = false
+ iconAriaLabel = "template-status-unsaved"
+ } else {
+ if (lastSyncResult === null) return null
+ activeIcon = lastSyncResult === "success" ? IoMdCloudDone : MdSyncProblem
+ activeColor = lastSyncResult === "success" ? "green.500" : "yellow.600"
+ isInteractive = true
+ iconAriaLabel =
+ lastSyncResult === "success"
+ ? "template-status-saved"
+ : "template-status-error"
+ }
}
const popupTitle =
@@ -121,13 +165,13 @@ export function TemplateStatus() {
: (storageError ?? "Failed to save changes. Please try again.")
return (
-
+
{/*
* The icon is always inside this same Box so its screen position never
* changes when the notification text appears or disappears.
*/}
-
-
+
-
-
+
-
-
+
+
{popupTitle}
-
-
+
+
{popupBody}
-
-
-
+
+
+
{notifText && (
-
{notifText}
-
+
)}
-
+
)
}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index bfb5fac..bc6d96c 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -31,13 +31,19 @@ import { PiPlus } from "@react-icons/all-files/pi/PiPlus"
import { PiPushPin } from "@react-icons/all-files/pi/PiPushPin"
import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
import { PiSquaresFourLight } from "@react-icons/all-files/pi/PiSquaresFourLight"
+import type React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
+import { useTemplateSync } from "../../hooks/useTemplateSync"
import type { TemplateRecord } from "../../storage"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
import { truncateTemplateName } from "../../utils"
+const PopoverContentAny = PopoverContent as unknown as React.FC<
+ Record
+>
+
const MAX_VISIBLE = 5
interface TemplatesDropdownProps {
@@ -48,6 +54,7 @@ export function TemplatesDropdown({
onViewAllTemplates,
}: TemplatesDropdownProps) {
const provider = useTemplateStorage()
+ const { saveNow } = useTemplateSync()
const { isOpen, onOpen, onClose } = useDisclosure()
const [templates, setTemplates] = useState([])
const [search, setSearch] = useState("")
@@ -59,14 +66,18 @@ export function TemplatesDropdown({
null,
)
- const { loadTemplate, setTemplateName, setTemplateId, resetToNewTemplate } =
- useEditorStore()
+ const { loadTemplate, resetToNewTemplate } = useEditorStore()
+ const hydrateTemplateMetadata = useEditorStore(
+ (s) => s.hydrateTemplateMetadata,
+ )
const templateId = useEditorStore((s) => s.templateId)
const templateName = useEditorStore((s) => s.templateName)
const transformations = useEditorStore((s) => s.transformations)
const syncStatus = useEditorStore((s) => s.syncStatus)
+ const hasUnsyncedChanges = useEditorStore(
+ (s) => s.localChangeVersion !== s.lastSyncedVersion,
+ )
const isPristine = useEditorStore((s) => s.isPristine)
- const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
@@ -140,14 +151,17 @@ export function TemplatesDropdown({
const doLoadTemplate = (record: TemplateRecord) => {
loadTemplate(record.transformations)
- setTemplateName(record.name)
- setTemplateId(record.id)
+ hydrateTemplateMetadata({
+ templateId: record.id,
+ templateName: record.name,
+ templateIsPrivate: record.isPrivate,
+ })
onClose()
setPendingTemplate(null)
}
const handleSelect = (record: TemplateRecord) => {
- if (isPristine || syncStatus === "saved") {
+ if (!hasUnsyncedChanges) {
doLoadTemplate(record)
} else {
setPendingTemplate(record)
@@ -190,17 +204,8 @@ export function TemplatesDropdown({
const handleSaveAndContinue = async () => {
if (!provider || !pendingTemplate || templateStorageWriteBlocked) return
- const state = useEditorStore.getState()
- setSyncStatus("saving")
try {
- await provider.saveTemplate({
- id: state.templateId ?? undefined,
- name: state.templateName,
- transformations: state.transformations.map(
- ({ id: _id, ...rest }) => rest,
- ),
- })
- setSyncStatus("saved")
+ await saveNow({ reason: "manual" })
doLoadTemplate(pendingTemplate)
} catch (err) {
const { denyTemplateStorageAccess, setSyncStatus } =
@@ -266,7 +271,7 @@ export function TemplatesDropdown({
/>
- {
+ onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleTogglePin(record)
}}
@@ -512,7 +517,7 @@ export function TemplatesDropdown({
>
) : null}
-
+
{
- const provider = useTemplateStorage()
const {
transformations,
addTransformation,
@@ -154,43 +154,17 @@ export const TransformationConfigSidebar: React.FC = () => {
_internalState,
_setTransformationToEdit,
_setSelectedTransformationKey,
+ setTransformationConfigFormDirty,
} = useEditorStore()
const syncStatus = useEditorStore((s) => s.syncStatus)
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
- const setSyncStatus = useEditorStore((s) => s.setSyncStatus)
-
- const save = useCallback(async () => {
- if (!provider) return
- const state = useEditorStore.getState()
- if (state.templateStorageWriteBlocked) return
- setSyncStatus("saving")
- try {
- const saved = await provider.saveTemplate({
- id: state.templateId ?? undefined,
- name: state.templateName,
- transformations: state.transformations.map(
- ({ id: _id, ...rest }) => rest,
- ),
- })
- useEditorStore.getState().setTemplateId(saved.id)
- setSyncStatus("saved")
- } catch (err) {
- const { denyTemplateStorageAccess } = useEditorStore.getState()
- if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
- return
- }
- setSyncStatus(
- "error",
- err instanceof Error ? err.message : "Failed to save",
- )
- }
- }, [provider, setSyncStatus])
+ const { saveNow } = useTemplateSync()
+ const hasUnsyncedChanges = useEditorStore(
+ (s) => s.localChangeVersion !== s.lastSyncedVersion,
+ )
+ const save = useCallback(() => saveNow({ reason: "sidebar" }), [saveNow])
const selectedTransformation = useMemo(() => {
return transformationSchema
@@ -280,6 +254,18 @@ export const TransformationConfigSidebar: React.FC = () => {
reset(defaultValues)
}, [reset, defaultValues])
+ useEffect(() => {
+ setTransformationConfigFormDirty(isDirty)
+ return () => setTransformationConfigFormDirty(false)
+ }, [isDirty, setTransformationConfigFormDirty])
+
+ const setDirtyValue = useCallback(
+ (name: string, value: unknown) => {
+ setValue(name, value, { shouldDirty: true, shouldTouch: true })
+ },
+ [setValue],
+ )
+
const values = watch()
const onClose = useCallback(() => {
@@ -413,8 +399,15 @@ export const TransformationConfigSidebar: React.FC = () => {
syncStatus,
hasAppliedInSession,
templateStorageWriteBlocked,
+ hasUnsyncedChanges,
}),
- [isDirty, syncStatus, hasAppliedInSession, templateStorageWriteBlocked],
+ [
+ isDirty,
+ syncStatus,
+ hasAppliedInSession,
+ templateStorageWriteBlocked,
+ hasUnsyncedChanges,
+ ],
)
const footerActions = useMemo(() => {
@@ -597,9 +590,6 @@ export const TransformationConfigSidebar: React.FC = () => {
const isCreatable = field.fieldProps?.isCreatable === true
const isClearable: boolean =
field.fieldProps?.isClearable ?? false
- const SelectComponent = isCreatable
- ? CreateableSelect
- : Select
// For creatable selects, find the value in options or create a custom one
const selectedValue = isCreatable
@@ -616,10 +606,10 @@ export const TransformationConfigSidebar: React.FC = () => {
(option) => option.value === controllerField.value,
)
- return (
-
+ formatCreateLabel={(inputValue: string) =>
`Use "${inputValue}"`
}
isClearable={isClearable}
@@ -648,6 +638,35 @@ export const TransformationConfigSidebar: React.FC = () => {
}),
}}
/>
+ ) : (
+
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 467e9f9..17bd177 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -39,7 +39,7 @@ import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useDebounce } from "../../hooks/useDebounce"
import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
-import { truncateTemplateName } from "../../utils"
+import { formatTemplateNameForUI, truncateTemplateName } from "../../utils"
import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
import { SettingsModal } from "../header/SettingsModal"
@@ -48,6 +48,20 @@ interface Props {
onClose(): void
}
+const FlexAny = Flex as unknown as React.ElementType
+const TextAny = Text as unknown as React.ElementType
+const AvatarAny = Avatar as unknown as React.ElementType
+const ButtonAny = Button as unknown as React.ElementType
+const SpinnerAny = Spinner as unknown as React.ElementType
+const BadgeAny = Badge as unknown as React.ElementType
+const InputGroupAny = InputGroup as unknown as React.ElementType
+const InputLeftElementAny = InputLeftElement as unknown as React.ElementType
+const InputAny = Input as unknown as React.ElementType
+const IconAny = Icon as unknown as React.ElementType
+const PopoverContentAny = PopoverContent as unknown as React.ElementType
+const PopoverBodyAny = PopoverBody as unknown as React.ElementType
+const DividerAny = Divider as unknown as React.ElementType
+
function formatRelativeTime(ts: number): string {
const now = Date.now()
// If the timestamp is within 10 seconds of now, show "Just now"
@@ -294,7 +308,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
}
return (
-
- }
+ leftIcon={ }
color="editorBattleshipGrey.500"
_hover={{ color: "editorBattleshipGrey.700", bg: "transparent" }}
px="0"
>
Go back
-
+
-
+
-
All templates
-
-
+
+
Browse and load templates created by you or shared with you.
-
+
- }
+ leftIcon={ }
px="4"
onClick={() => {
resetToNewTemplate()
@@ -348,11 +362,11 @@ export function TemplatesLibraryView({ onClose }: Props) {
}}
>
New template
-
-
+
+
{/* Controls bar */}
-
-
-
-
-
-
+
+
+
+ setSearchInput(e.target.value)}
+ onChange={(e: React.ChangeEvent) =>
+ setSearchInput(e.target.value)
+ }
onKeyDown={handleSearchKeyDown}
bg="white"
borderColor="gray.200"
@@ -382,7 +398,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
boxShadow: "0 0 0 1px #3182ce",
}}
/>
-
+
-
-
+ 0 ? 1 : 0.5}
>
Created by
-
+
{creatorFilter.length > 0 && (
-
+
{creatorFilter.length}
-
+
)}
-
-
+
+
-
-
+
({
label: name,
@@ -466,8 +482,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
selectedFirst
showSelectedSeparator
/>
-
-
+ setCreatorFilter([])}
>
Clear selected
-
-
-
+
+
+
-
+
{/* Scrollable table area */}
-
{loading ? (
-
-
-
+
+
+
) : (
<>
{/* Table header */}
-
- Name
+ Name
- Created by
+ Created by
- Visibility
+ Visibility
- Last updated
+ Last updated
-
+
{/* Filtered templates */}
{filtered.length === 0 && !showCurrentRow ? (
-
-
+
{search ||
visibilityFilter.length > 0 ||
creatorFilter.length > 0
@@ -562,8 +582,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
: shouldShowCurrent
? "No other saved templates"
: "No saved templates yet"}
-
-
+
+
) : (
)}
-
+
{isSettingsOpen && (
setIsSettingsOpen(false)}
/>
)}
-
+
)
}
@@ -653,8 +673,12 @@ function TemplateRow({
canDelete = true,
}: TemplateRowProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const recordNameUI = formatTemplateNameForUI(record.name)
+ const MenuButtonAny = MenuButton as unknown as React.ElementType
+ const MenuListAny = MenuList as unknown as React.ElementType
+ const MenuItemAny = MenuItem as unknown as React.ElementType
return (
-
{isPinning ? (
-
+
) : (
-
-
+
{truncateTemplateName(record.name)}
-
+
{isCurrent && (
-
+
Current
-
+
)}
-
-
+
{record.transformations.length} transformation
{record.transformations.length !== 1 ? "s" : ""}
-
+
{/* Creator */}
-
-
+
-
{record.createdBy.name || record.createdBy.email}
-
-
+
{record.createdBy.email}
-
+
-
+
{/* Visibility */}
-
+
-
{record.isPrivate ? "Only to me" : "Shared with everyone"}
-
-
+
+
{/* Last updated */}
-
{formatRelativeTime(record.updatedAt)}
-
+
{/* Row actions menu + delete confirmation popup */}
@@ -788,7 +812,7 @@ function TemplateRow({
onClick={(e) => e.stopPropagation()}
>
- e.stopPropagation()}
+ onClick={(e: React.MouseEvent) =>
+ e.stopPropagation()
+ }
>
-
-
-
+ e.stopPropagation()}
+ onClick={(e: React.MouseEvent) =>
+ e.stopPropagation()
+ }
>
- }
- onClick={(e) => {
+ }
+ onClick={(e: React.MouseEvent) => {
e.stopPropagation()
onSettings(record)
}}
>
Settings
-
- }
+
+ }
color={canDelete ? "red.500" : "gray.400"}
display="flex"
alignItems="center"
_hover={{ bg: canDelete ? "red.50" : "transparent" }}
isDisabled={!canDelete}
- onClick={(e) => {
+ onClick={(e: React.MouseEvent) => {
if (!canDelete) return
e.stopPropagation()
setShowDeleteConfirm(true)
}}
>
Delete
-
-
+
+
- e.stopPropagation()}
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
-
-
+
+
Are you sure you want to delete this template? This action is
irreversible.
-
-
-
+
+ setShowDeleteConfirm(false)}
@@ -870,8 +898,8 @@ function TemplateRow({
}}
>
Cancel
-
-
+ }
@@ -881,11 +909,11 @@ function TemplateRow({
}}
>
Delete
-
-
-
-
+
+
+
+
-
+
)
}
diff --git a/packages/imagekit-editor-dev/src/utils/index.ts b/packages/imagekit-editor-dev/src/utils/index.ts
index 0b0d211..2d284da 100644
--- a/packages/imagekit-editor-dev/src/utils/index.ts
+++ b/packages/imagekit-editor-dev/src/utils/index.ts
@@ -13,6 +13,54 @@ export const safeBtoa = (str: string): string => {
}
}
+const decodeHtmlEntitiesOnce = (input: string): string => {
+ if (!input || !input.includes("&")) return input
+
+ // Some names were saved with malformed entities missing the trailing semicolon,
+ // e.g. "<>" where the "lt" is missing ";". Normalize those first.
+ const normalized = input.replace(/&(amp|lt|gt|quot|apos|nbsp)(?!;)/g, "&$1;")
+
+ // Decode a small, deterministic set of HTML entities + numeric references.
+ // Intentionally does NOT decode broader named entities like © to keep
+ // behavior stable across environments and avoid surprising transforms.
+ const named: Record = {
+ amp: "&",
+ lt: "<",
+ gt: ">",
+ quot: '"',
+ apos: "'",
+ nbsp: " ",
+ }
+
+ return normalized.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (m, g1) => {
+ if (typeof g1 !== "string") return m
+ if (g1.startsWith("#x") || g1.startsWith("#X")) {
+ const codePoint = Number.parseInt(g1.slice(2), 16)
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : m
+ }
+ if (g1.startsWith("#")) {
+ const codePoint = Number.parseInt(g1.slice(1), 10)
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : m
+ }
+ return named[g1] ?? m
+ })
+}
+
+/**
+ * Normalizes a template name for display in the UI.
+ * Handles cases where names were stored with HTML entities (sometimes double-encoded).
+ */
+export const formatTemplateNameForUI = (rawName: string): string => {
+ let s = rawName ?? ""
+ // Decode up to a few times to handle double-encoding like "<" → "<" → "<".
+ for (let i = 0; i < 3; i++) {
+ const next = decodeHtmlEntitiesOnce(s)
+ if (next === s) break
+ s = next
+ }
+ return s
+}
+
/**
* Step validation without floating‑point error.
* We scale both value and step to integers using their max decimal precision
@@ -96,8 +144,9 @@ export const extractImagePath = (imageUrl: string): string => {
}
export const truncateTemplateName = (name: string) => {
- if (name.length <= TEMPLATE_NAME_UI_MAX_LENGTH) {
- return name
+ const normalized = formatTemplateNameForUI(name)
+ if (normalized.length <= TEMPLATE_NAME_UI_MAX_LENGTH) {
+ return normalized
}
- return `${name.slice(0, TEMPLATE_NAME_UI_MAX_LENGTH)}...`
+ return `${normalized.slice(0, TEMPLATE_NAME_UI_MAX_LENGTH)}...`
}
diff --git a/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts b/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts
new file mode 100644
index 0000000..e6374e2
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/utils/templateNameFormatting.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest"
+import { formatTemplateNameForUI } from "./index"
+
+describe("formatTemplateNameForUI", () => {
+ it("returns plain names unchanged", () => {
+ expect(formatTemplateNameForUI("Untitled Template")).toBe(
+ "Untitled Template",
+ )
+ expect(formatTemplateNameForUI("A & B")).toBe("A & B")
+ expect(formatTemplateNameForUI("Hello <>")).toBe("Hello <>")
+ })
+
+ it("decodes common HTML entities", () => {
+ expect(formatTemplateNameForUI("A & B")).toBe("A & B")
+ expect(formatTemplateNameForUI("<tag>")).toBe("")
+ expect(formatTemplateNameForUI("She said "hi"")).toBe(
+ 'She said "hi"',
+ )
+ expect(formatTemplateNameForUI("It's ok")).toBe("It's ok")
+ expect(formatTemplateNameForUI("a b")).toBe("a b")
+ })
+
+ it("decodes numeric entities (decimal + hex)", () => {
+ expect(formatTemplateNameForUI("<")).toBe("<")
+ expect(formatTemplateNameForUI("<")).toBe("<")
+ expect(formatTemplateNameForUI(">>")).toBe(">>")
+ expect(formatTemplateNameForUI("👍")).toBe("👍")
+ })
+
+ it("decodes double-encoded entity sequences", () => {
+ expect(formatTemplateNameForUI("<")).toBe("<")
+ expect(formatTemplateNameForUI("<>")).toBe("<>")
+ expect(formatTemplateNameForUI("Tom & Jerry")).toBe("Tom & Jerry")
+ })
+
+ it("handles malformed entities missing semicolons (common in stored template names)", () => {
+ expect(formatTemplateNameForUI("<>>")).toBe("<>>")
+
+ // Other missing-semicolon variants
+ expect(formatTemplateNameForUI("<div>")).toBe("")
+ expect(formatTemplateNameForUI("A B")).toBe("A B")
+ })
+
+ it("leaves unknown entities as-is", () => {
+ expect(formatTemplateNameForUI("&doesNotExist;")).toBe("&doesNotExist;")
+ expect(formatTemplateNameForUI("© 2026")).toBe("© 2026")
+ })
+})
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 867a375..9e1d7fd 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-dev-24-04-2026.9",
+ "version": "2.2.0-dev-29-04-2026.5",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From d8f95fc39c65ed76863947ede044e2a1b0455b2e Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Wed, 29 Apr 2026 21:15:00 +0530
Subject: [PATCH 52/64] fix: increase timeout for failing test
---
.../templates/TemplatesLibraryView.test.tsx | 86 ++++++++++---------
1 file changed, 45 insertions(+), 41 deletions(-)
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
index 143fe08..ffe5754 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
@@ -151,55 +151,59 @@ describe("TemplatesLibraryView (virtualized)", () => {
vi.useRealTimers()
})
- it("does not render thousands of rows at once; renders more on scroll", async () => {
- const now = Date.now()
- const templates = Array.from({ length: 1000 }).map((_, i) =>
- makeTemplate({
- id: `t-${i}`,
- name: `Template ${i}`,
- // Ensure deterministic sort: newest first.
- updatedAt: now - i,
- createdAt: now - i,
- createdBy: {
- userId: `u-${i}`,
- name: `User ${i}`,
- email: `u${i}@ex.com`,
- },
- }),
- )
+ it(
+ "does not render thousands of rows at once; renders more on scroll",
+ async () => {
+ const now = Date.now()
+ const templates = Array.from({ length: 1000 }).map((_, i) =>
+ makeTemplate({
+ id: `t-${i}`,
+ name: `Template ${i}`,
+ // Ensure deterministic sort: newest first.
+ updatedAt: now - i,
+ createdAt: now - i,
+ createdBy: {
+ userId: `u-${i}`,
+ name: `User ${i}`,
+ email: `u${i}@ex.com`,
+ },
+ }),
+ )
- useEditorStore.setState({
- isPristine: true,
- templateId: null,
- templateName: "New template",
- transformations: [],
- syncStatus: "saved",
- localChangeVersion: 1,
- lastSyncedVersion: 1,
- } as unknown as Parameters[0])
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
- renderWithProvider({ templates })
+ renderWithProvider({ templates })
- // Wait for view to load.
- expect(await screen.findByText("All templates")).toBeTruthy()
+ // Wait for view to load.
+ expect(await screen.findByText("All templates")).toBeTruthy()
- const scrollEl = await screen.findByTestId("templates-library-scroll")
+ const scrollEl = await screen.findByTestId("templates-library-scroll")
- // Top row should render (Template 0 is newest due to updatedAt).
- expect(await screen.findByText("Template 0")).toBeTruthy()
+ // Top row should render (Template 0 is newest due to updatedAt).
+ expect(await screen.findByText("Template 0")).toBeTruthy()
- // A far-down row should not be mounted initially.
- expect(screen.queryByText("Template 900")).toBeNull()
+ // A far-down row should not be mounted initially.
+ expect(screen.queryByText("Template 900")).toBeNull()
- // Scroll down enough to bring later items into view.
- act(() => {
- fireEvent.scroll(scrollEl, { target: { scrollTop: 84 * 900 } })
- })
+ // Scroll down enough to bring later items into view.
+ act(() => {
+ fireEvent.scroll(scrollEl, { target: { scrollTop: 84 * 900 } })
+ })
- await waitFor(() => {
- expect(screen.getByText("Template 900")).toBeTruthy()
- })
- })
+ await waitFor(() => {
+ expect(screen.getByText("Template 900")).toBeTruthy()
+ })
+ },
+ 15 * 1000,
+ )
it('includes a virtualized "Current" row when the active template exists', async () => {
const now = Date.now()
From ada2337ccd231c895b81d6e36d3276ab06139689 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 01:54:02 +0530
Subject: [PATCH 53/64] fix: ui fixes around template library, dropdown and
settings modals
---
packages/imagekit-editor-dev/package.json | 2 +-
.../src/components/header/SettingsModal.tsx | 366 ++++++++++--------
.../header/TemplatesDropdown.test.tsx | 117 +++++-
.../components/header/TemplatesDropdown.tsx | 150 ++++---
.../src/components/header/index.tsx | 70 ++--
.../templates/TemplatesLibraryView.test.tsx | 126 +++++-
.../templates/TemplatesLibraryView.tsx | 140 +++----
packages/imagekit-editor/package.json | 2 +-
8 files changed, 614 insertions(+), 359 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index 1dfab46..772747e 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-dev-29-04-2026.5",
+ "version": "2.2.0-dev-29-04-2026.10",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index e08d26a..5eafbb9 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -1,14 +1,4 @@
-import {
- Box,
- Button,
- Flex,
- FormControl,
- FormLabel,
- Icon,
- IconButton,
- Input,
- Text,
-} from "@chakra-ui/react"
+import { Box, Flex, Icon, IconButton, Input, Text } from "@chakra-ui/react"
import { PiGlobe } from "@react-icons/all-files/pi/PiGlobe"
import { PiLock } from "@react-icons/all-files/pi/PiLock"
import { PiTrash } from "@react-icons/all-files/pi/PiTrash"
@@ -17,95 +7,105 @@ import type React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import Select, { type StylesConfig } from "react-select"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
-import { useTemplateSync } from "../../hooks/useTemplateSync"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
+import type { TemplateRecord } from "../../storage/types"
import { useEditorStore } from "../../store"
import { formatTemplateNameForUI } from "../../utils"
-const FlexAny = Flex as unknown as React.FC>
+// ---------------------------------------------------------------------------
+// Type casts — Chakra's strict generic signatures conflict with our JSX usage
+// ---------------------------------------------------------------------------
+const FlexAny = Flex as unknown as React.ElementType
+const TextAny = Text as unknown as React.ElementType
+const IconButtonAny = IconButton as unknown as React.ElementType
+const InputAny = Input as unknown as React.ElementType
-function visibilityFromKnownPrivate(
- isPrivate: boolean | null,
-): "everyone" | "onlyMe" {
- if (isPrivate === false) {
- return "everyone"
- }
- return "onlyMe"
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+export interface SettingsModalProps {
+ /** The template whose settings are being edited. All operations act on this record. */
+ data: TemplateRecord
+ onClose(): void
+ /** Called with the updated record after a successful save. */
+ onSaved?(updated: TemplateRecord): void
+ /** Called after the template is successfully deleted. */
+ onDeleted?(): void
}
-interface SettingsModalProps {
- onClose: () => void
- /** From header refetch of template visibility; seeds the dropdown before async getTemplate completes. */
- knownIsPrivate: boolean | null
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+type Visibility = "everyone" | "onlyMe"
+
+function visibilityFromRecord(record: TemplateRecord): Visibility {
+ return record.isPrivate ? "onlyMe" : "everyone"
}
-export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function SettingsModal({
+ data,
+ onClose,
+ onSaved,
+ onDeleted,
+}: SettingsModalProps) {
const provider = useTemplateStorage()
- const templateId = useEditorStore((s) => s.templateId)
- const templateName = useEditorStore((s) => s.templateName)
- const setTemplateIsPrivate = useEditorStore((s) => s.setTemplateIsPrivate)
- const resetToNewTemplate = useEditorStore((s) => s.resetToNewTemplate)
- const denyTemplateStorageAccess = useEditorStore(
- (s) => s.denyTemplateStorageAccess,
- )
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
- const { saveNow } = useTemplateSync()
+ const denyTemplateStorageAccess = useEditorStore(
+ (s) => s.denyTemplateStorageAccess,
+ )
- // Stable ref so the getTemplate effect doesn't re-run when onClose identity changes.
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
const [localName, setLocalName] = useState(() =>
- formatTemplateNameForUI(templateName),
+ formatTemplateNameForUI(data.name),
)
- const [localVisibility, setLocalVisibility] = useState<"everyone" | "onlyMe">(
- () => visibilityFromKnownPrivate(knownIsPrivate),
+ const [localVisibility, setLocalVisibility] = useState(() =>
+ visibilityFromRecord(data),
)
+ // Whether the current user is allowed to change visibility (only the creator can).
const [canChangeVisibility, setCanChangeVisibility] = useState(true)
+
const [isDeleting, setIsDeleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
- useEffect(() => {
- setLocalName(formatTemplateNameForUI(templateName))
- }, [templateName])
-
- useEffect(() => {
- setLocalVisibility(visibilityFromKnownPrivate(knownIsPrivate))
- }, [knownIsPrivate])
-
- // Authoritative visibility from API (may arrive after header snapshot).
- // onClose is intentionally read via ref so its identity change never re-triggers this effect.
+ // Fetch authoritative visibility + creator check from the API.
useEffect(() => {
let cancelled = false
- if (!provider || !templateId) {
+ if (!provider) {
setCanChangeVisibility(true)
return
}
provider
- .getTemplate(templateId)
+ .getTemplate(data.id)
.then((record) => {
if (cancelled) return
if (!record) {
setCanChangeVisibility(true)
return
}
- setLocalVisibility(record.isPrivate ? "onlyMe" : "everyone")
+ setLocalVisibility(visibilityFromRecord(record))
const session = provider.getCurrentUserSession() as {
id?: string
} | null
setCanChangeVisibility(record.createdBy.userId === session?.id)
})
.catch((err) => {
+ if (cancelled) return
if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
+ applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
) {
onCloseRef.current()
}
@@ -114,62 +114,62 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
return () => {
cancelled = true
}
- // denyTemplateStorageAccess is a stable Zustand action — safe to include.
- // onClose is intentionally excluded; we use onCloseRef instead.
- }, [provider, templateId, denyTemplateStorageAccess])
+ }, [provider, data.id, denyTemplateStorageAccess])
+
+ // Close on Escape
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.stopPropagation()
+ onClose()
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [onClose])
- const saveTemplate = async (opts?: { closeAfter?: boolean }) => {
+ // -------------------------------------------------------------------------
+ // Save: patches only name + visibility; preserves all other fields
+ // -------------------------------------------------------------------------
+ const handleSave = async () => {
if (!provider || !localName.trim() || templateStorageWriteBlocked) return
+ setIsSaving(true)
try {
- setIsSaving(true)
- const saved = await saveNow({
- reason: "settings",
- overrides: {
- name: localName.trim(),
- isPrivate: localVisibility === "onlyMe",
- },
- })
- if (!saved) return
- useEditorStore.getState().hydrateTemplateMetadata({
- templateId: saved.id,
- templateName: localName.trim(),
- templateIsPrivate: saved.isPrivate,
+ const updated = await provider.saveTemplate({
+ ...data,
+ name: localName.trim(),
+ isPrivate: localVisibility === "onlyMe",
+ // transformations are preserved from the original data record
+ transformations: data.transformations,
})
- if (opts?.closeAfter !== false) {
- onClose()
- }
+ onSaved?.(updated)
+ onClose()
} catch (err) {
if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
+ applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
) {
- if (opts?.closeAfter !== false) {
- onClose()
- }
- return
+ onClose()
}
} finally {
setIsSaving(false)
}
}
+ // -------------------------------------------------------------------------
+ // Delete
+ // -------------------------------------------------------------------------
const handleDelete = async () => {
- if (!provider || !templateId) return
- if (!provider.deleteTemplate) return
+ if (!provider || !provider.deleteTemplate) return
setIsDeleting(true)
-
try {
- await provider.deleteTemplate(templateId)
- resetToNewTemplate()
+ await provider.deleteTemplate(data.id)
+ onDeleted?.()
onClose()
} catch (err) {
if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
+ applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
) {
onClose()
return
@@ -180,20 +180,9 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
}
}
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- event.stopPropagation()
- onClose()
- }
- }
- window.addEventListener("keydown", handleKeyDown)
- return () => {
- window.removeEventListener("keydown", handleKeyDown)
- }
- }, [onClose])
-
+ // -------------------------------------------------------------------------
+ // react-select styles
+ // -------------------------------------------------------------------------
const selectStyles = useMemo<
StylesConfig<{ value: string; label: string }, false>
>(
@@ -206,18 +195,15 @@ export function SettingsModal({ onClose, knownIsPrivate }: SettingsModalProps) {
backgroundColor: canChangeVisibility ? base.backgroundColor : "#F7FAFC",
opacity: canChangeVisibility ? 1 : 0.6,
}),
- menu: (base) => ({
- ...base,
- zIndex: 10,
- }),
- option: (base) => ({
- ...base,
- fontSize: "12px",
- }),
+ menu: (base) => ({ ...base, zIndex: 10 }),
+ option: (base) => ({ ...base, fontSize: "12px" }),
}),
[canChangeVisibility],
)
+ // -------------------------------------------------------------------------
+ // Render
+ // -------------------------------------------------------------------------
return (
-
+
Template Settings
-
-
+
-
+
{/* Template Name */}
-
-
+
Template Name
-
-
+ setLocalName(e.target.value)}
+ onChange={(e: React.ChangeEvent) =>
+ setLocalName(e.target.value)
+ }
placeholder="Enter template name"
fontSize="sm"
/>
-
+
{/* Visibility */}
-
-
+
Visibility
-
+
-
+
+
{/* Footer */}
-
- }
- onClick={handleDelete}
- isLoading={isDeleting}
- isDisabled={!templateId || isSaving}
- >
- Delete
-
+ {/* Delete button — only shown when deleteTemplate is supported */}
+ {provider?.deleteTemplate ? (
+
+
+
+ {isDeleting ? "Deleting…" : "Delete"}
+
+
+ ) : (
+
+ )}
-
-
+
Cancel
-
- saveTemplate({ closeAfter: true })}
- isLoading={isSaving}
- isDisabled={
- !localName.trim() || isDeleting || templateStorageWriteBlocked
+
+
- Save
-
-
-
+ {isSaving ? "Saving…" : "Save"}
+
+
+
)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.test.tsx
index 606c8e8..ed22dab 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.test.tsx
@@ -92,8 +92,13 @@ describe("TemplatesDropdown", () => {
await openDropdown()
expect(await screen.findByText("Vintage Look")).toBeTruthy()
- expect(screen.queryByText("Ada Lovelace")).toBeNull()
- expect(screen.queryByLabelText(/Pin template Vintage Look/i)).toBeNull()
+ // Creator text exists in DOM but is hidden (not visible until hover)
+ const creatorElement = screen.queryByTestId(
+ `templates-dropdown-creator-${t.id}`,
+ )
+ expect(creatorElement).toBeTruthy()
+ // Pin button is in the DOM but hidden (visibility: hidden) before hover
+ expect(screen.getByLabelText(/Pin template Vintage Look/i)).toBeTruthy()
const row = screen
.getByText("Vintage Look")
@@ -104,6 +109,7 @@ describe("TemplatesDropdown", () => {
fireEvent.mouseEnter(row as Element)
})
+ // After hover, creator is visible
expect(screen.getByText("Ada Lovelace")).toBeTruthy()
expect(
screen.getByTestId("templates-dropdown-creator-avatar-t-1"),
@@ -114,8 +120,8 @@ describe("TemplatesDropdown", () => {
fireEvent.mouseLeave(row as Element)
})
- expect(screen.queryByText("Ada Lovelace")).toBeNull()
- expect(screen.queryByLabelText(/Pin template Vintage Look/i)).toBeNull()
+ // After leaving, pin button is still in DOM (just hidden)
+ expect(screen.getByLabelText(/Pin template Vintage Look/i)).toBeTruthy()
})
it("keeps pin icon visible when a template is pinned (even without hover)", async () => {
@@ -143,7 +149,10 @@ describe("TemplatesDropdown", () => {
expect(
screen.getByLabelText(/Unpin template My Pinned Template/i),
).toBeTruthy()
- expect(screen.queryByText("Grace")).toBeNull()
+ // Creator is in DOM but hidden (not interactable)
+ expect(
+ screen.queryByTestId(`templates-dropdown-creator-${pinned.id}`),
+ ).toBeTruthy()
})
it('shows a "Current" indicator row when the current template is active', async () => {
@@ -151,7 +160,7 @@ describe("TemplatesDropdown", () => {
isPristine: false,
templateId: null,
templateName: "In progress",
- transformations: [{ id: "x" }],
+ transformations: [],
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
@@ -212,6 +221,11 @@ describe("TemplatesDropdown", () => {
})
expect(screen.getByText("Ada")).toBeTruthy()
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
+ expect(
+ screen
+ .getByTestId("templates-dropdown-row-t-1")
+ .getAttribute("data-active"),
+ ).toBe("true")
// ArrowDown should move to next result.
act(() => {
@@ -231,4 +245,95 @@ describe("TemplatesDropdown", () => {
})
expect(screen.getByText("Grace")).toBeTruthy()
})
+
+ it("maintains consistent row height when hovering (no layout shift)", async () => {
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ const t = makeTemplate({
+ id: "t-1",
+ name: "Test Template",
+ isPinned: false,
+ createdBy: { userId: "u-1", name: "John Doe", email: "john@ex.com" },
+ })
+
+ renderWithProvider({ templates: [t] })
+ await openDropdown()
+
+ expect(await screen.findByText("Test Template")).toBeTruthy()
+
+ const row = screen
+ .getByText("Test Template")
+ .closest('[data-testid="templates-dropdown-row-t-1"]')
+ expect(row).toBeTruthy()
+
+ // Count child nodes in the row before hover — the DOM structure must not change
+ const childCountBefore = (row as HTMLElement).querySelectorAll("*").length
+
+ // Hover over the row
+ act(() => {
+ fireEvent.mouseEnter(row as Element)
+ })
+
+ // DOM structure must be identical — no nodes added or removed
+ const childCountOnHover = (row as HTMLElement).querySelectorAll("*").length
+ expect(childCountOnHover).toBe(childCountBefore)
+
+ // Move away from row
+ act(() => {
+ fireEvent.mouseLeave(row as Element)
+ })
+
+ const childCountAfterHover = (row as HTMLElement).querySelectorAll(
+ "*",
+ ).length
+ expect(childCountAfterHover).toBe(childCountBefore)
+ })
+
+ it("loads the selected template on Enter (keyboard)", async () => {
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ const t1 = makeTemplate({
+ id: "t-1",
+ name: "Alpha",
+ createdBy: { userId: "u-1", name: "Ada", email: "ada@ex.com" },
+ })
+ const t2 = makeTemplate({
+ id: "t-2",
+ name: "Alpine",
+ createdBy: { userId: "u-2", name: "Grace", email: "grace@ex.com" },
+ })
+
+ renderWithProvider({ templates: [t1, t2] })
+ await openDropdown()
+
+ const input = await screen.findByPlaceholderText("Search templates...")
+ act(() => {
+ fireEvent.change(input, { target: { value: "al" } })
+ })
+
+ // ArrowDown to activate first result, then Enter to load it.
+ act(() => {
+ fireEvent.keyDown(input, { key: "ArrowDown" })
+ fireEvent.keyDown(input, { key: "Enter" })
+ })
+
+ expect(useEditorStore.getState().templateId).toBe("t-1")
+ expect(useEditorStore.getState().templateName).toBe("Alpha")
+ })
})
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 5ebf4ed..b884b7d 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -176,6 +176,18 @@ export function TemplatesDropdown({
.slice(0, MAX_VISIBLE)
}, [templates, templateId, search, shouldShowCurrent, templateName])
+ useEffect(() => {
+ if (!isOpen) return
+ // If the hovered template is no longer in the filtered list (e.g. search changed),
+ // clear the hover so keyboard navigation starts from the top again.
+ if (
+ hoveredTemplateId &&
+ !filtered.some((t) => t.id === hoveredTemplateId)
+ ) {
+ setHoveredTemplateId(null)
+ }
+ }, [isOpen, hoveredTemplateId, filtered])
+
if (!provider) return null
const templateNameUI = formatTemplateNameForUI(templateName)
@@ -188,6 +200,16 @@ export function TemplatesDropdown({
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (filtered.length === 0) return
+ if (e.key === "Enter") {
+ e.preventDefault()
+ const record =
+ (hoveredTemplateId
+ ? filtered.find((t) => t.id === hoveredTemplateId)
+ : null) ?? filtered[0]
+ if (record) handleSelect(record)
+ return
+ }
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
e.preventDefault()
@@ -310,6 +332,7 @@ export function TemplatesDropdown({
borderRadius="md"
paddingX="4"
paddingY="2"
+ height="10"
marginX="2"
_hover={{ bg: "editorGray.200" }}
transition="background-color 0.15s"
@@ -469,7 +492,6 @@ export function TemplatesDropdown({
filtered.map((record) =>
(() => {
const isHovered = hoveredTemplateId === record.id
- const shouldShowPinButton = isHovered || record.isPinned
const creatorLabel =
record.createdBy.name || record.createdBy.email
const recordNameUI = formatTemplateNameForUI(record.name)
@@ -484,6 +506,8 @@ export function TemplatesDropdown({
alignItems="center"
gap="3"
role="group"
+ data-active={isHovered ? "true" : undefined}
+ bg={isHovered ? "editorGray.100" : "transparent"}
_hover={{ bg: "editorGray.100" }}
data-testid={`templates-dropdown-row-${record.id}`}
onClick={() => handleSelect(record)}
@@ -518,70 +542,72 @@ export function TemplatesDropdown({
{/* Creator on hover + pin (always visible for pinned, hover for others) */}
- {/* Creator: only on hover */}
- {isHovered ? (
-
+
+
-
+
+
+ {/* Pin: always rendered, shown/hidden via opacity to avoid layout shift */}
+ ) => {
+ e.stopPropagation()
+ handleTogglePin(record)
+ }}
+ >
+ {pinningId === record.id ? (
+
-
- {creatorLabel}
-
-
- ) : null}
-
- {/* Pin */}
- {shouldShowPinButton ? (
- ) => {
- e.stopPropagation()
- handleTogglePin(record)
- }}
- >
- {pinningId === record.id ? (
-
- ) : (
-
- )}
-
- ) : null}
+ />
+ ) : (
+
+ )}
+
)
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 05162b5..5a95ce0 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -15,6 +15,7 @@ import { PiX } from "@react-icons/all-files/pi/PiX"
import React, { useEffect, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
+import type { TemplateRecord } from "../../storage/types"
import {
type FileElement,
type RequiredMetadata,
@@ -69,14 +70,16 @@ export const Header = ({
const syncStatus = useEditorStore((s) => s.syncStatus)
const provider = useTemplateStorage()
- const [isPrivate, setIsPrivate] = useState(null)
+ const [activeRecord, setActiveRecord] = useState(null)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
+ // Fetch the active template record whenever templateId changes or after a save.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: syncStatus intentionally triggers a refetch after saves
useEffect(() => {
let cancelled = false
if (!provider || !templateId) {
- setIsPrivate(null)
+ setActiveRecord(null)
return
}
@@ -84,51 +87,13 @@ export const Header = ({
.getTemplate(templateId)
.then((record) => {
if (cancelled) return
- setIsPrivate(record ? record.isPrivate : null)
+ setActiveRecord(record ?? null)
})
.catch((err) => {
if (cancelled) return
const { denyTemplateStorageAccess } = useEditorStore.getState()
- if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
- return
- }
- setIsPrivate(null)
- })
-
- return () => {
- cancelled = true
- }
- }, [provider, templateId])
-
- // Refetch template visibility when it's saved
- useEffect(() => {
- let cancelled = false
-
- if (!provider || !templateId || syncStatus !== "saved") {
- return
- }
-
- provider
- .getTemplate(templateId)
- .then((record) => {
- if (cancelled) return
- setIsPrivate(record ? record.isPrivate : null)
- })
- .catch((err) => {
- if (cancelled) return
- const { denyTemplateStorageAccess } = useEditorStore.getState()
- if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
- return
- }
- setIsPrivate(null)
+ applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
+ setActiveRecord(null)
})
return () => {
@@ -154,7 +119,7 @@ export const Header = ({
{templateId && (
@@ -277,11 +242,22 @@ export const Header = ({
label="Close"
onClick={onClose}
/>
- {isSettingsOpen && (
+ {isSettingsOpen && activeRecord && (
setIsSettingsOpen(false)}
+ onSaved={(updated) => {
+ setActiveRecord(updated)
+ useEditorStore.getState().hydrateTemplateMetadata({
+ templateId: updated.id,
+ templateName: updated.name,
+ templateIsPrivate: updated.isPrivate,
+ })
+ }}
+ onDeleted={() => {
+ useEditorStore.getState().resetToNewTemplate()
+ }}
/>
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
index ffe5754..860a7e3 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
@@ -319,7 +319,6 @@ describe("TemplatesLibraryView (virtualized)", () => {
// ArrowDown cycles back to first result.
act(() => {
fireEvent.keyDown(searchInput, { key: "ArrowDown" })
- fireEvent.keyDown(searchInput, { key: "ArrowDown" })
})
expect(
screen
@@ -339,4 +338,129 @@ describe("TemplatesLibraryView (virtualized)", () => {
vi.useRealTimers()
})
+
+ it("does not cycle with ArrowUp/ArrowDown when list size exceeds 200", async () => {
+ const now = Date.now()
+ const templates = Array.from({ length: 205 }).map((_, i) =>
+ makeTemplate({
+ id: `t-${i}`,
+ name: `Template ${i}`,
+ updatedAt: now - i,
+ createdAt: now - i,
+ createdBy: {
+ userId: `u-${i}`,
+ name: `User ${i}`,
+ email: `u${i}@ex.com`,
+ },
+ }),
+ )
+
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider({ templates })
+
+ expect(await screen.findByText("All templates")).toBeTruthy()
+ const scrollEl = await screen.findByTestId("templates-library-scroll")
+
+ const searchInput = await screen.findByPlaceholderText(
+ "Search templates...",
+ )
+
+ // Move to the last row via ArrowDown.
+ act(() => {
+ for (let i = 0; i < 205; i++) {
+ fireEvent.keyDown(searchInput, { key: "ArrowDown" })
+ }
+ })
+
+ // Scroll near the bottom so the last row is mounted for assertion.
+ act(() => {
+ fireEvent.scroll(scrollEl, { target: { scrollTop: 84 * 204 } })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText("Template 204")).toBeTruthy()
+ })
+
+ expect(
+ screen
+ .getByTestId("templates-library-row-t-204")
+ .getAttribute("data-active"),
+ ).toBe("true")
+
+ // One more ArrowDown should NOT wrap back to the first row.
+ act(() => {
+ fireEvent.keyDown(searchInput, { key: "ArrowDown" })
+ })
+
+ expect(
+ screen
+ .getByTestId("templates-library-row-t-204")
+ .getAttribute("data-active"),
+ ).toBe("true")
+ })
+
+ it("loads the selected template on Enter in the library", async () => {
+ const now = Date.now()
+ const t1 = makeTemplate({
+ id: "t-1",
+ name: "Template 1",
+ updatedAt: now,
+ createdAt: now,
+ createdBy: { userId: "u-1", name: "Ada", email: "ada@ex.com" },
+ })
+ const t2 = makeTemplate({
+ id: "t-2",
+ name: "Template 2",
+ updatedAt: now - 1,
+ createdAt: now - 1,
+ createdBy: { userId: "u-2", name: "Grace", email: "grace@ex.com" },
+ })
+ const templates = [t1, t2]
+
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider({ templates })
+
+ expect(await screen.findByText("All templates")).toBeTruthy()
+ const searchInput = await screen.findByPlaceholderText(
+ "Search templates...",
+ )
+
+ // Navigate to first template with ArrowDown
+ act(() => {
+ fireEvent.keyDown(searchInput, { key: "ArrowDown" })
+ })
+
+ expect(
+ screen
+ .getByTestId("templates-library-row-t-1")
+ .getAttribute("data-active"),
+ ).toBe("true")
+
+ // Press Enter to load it
+ act(() => {
+ fireEvent.keyDown(searchInput, { key: "Enter" })
+ })
+
+ // Verify the template was loaded into the store
+ expect(useEditorStore.getState().templateId).toBe("t-1")
+ expect(useEditorStore.getState().templateName).toBe("Template 1")
+ })
})
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 17bd177..97963bb 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -9,18 +9,14 @@ import {
Input,
InputGroup,
InputLeftElement,
- Menu,
- MenuButton,
- MenuItem,
- MenuList,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
Spinner,
Text,
+ Tooltip,
} from "@chakra-ui/react"
-import { BsThreeDots } from "@react-icons/all-files/bs/BsThreeDots"
import { PiArrowLeft } from "@react-icons/all-files/pi/PiArrowLeft"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
import { PiGear } from "@react-icons/all-files/pi/PiGear"
@@ -61,6 +57,7 @@ const IconAny = Icon as unknown as React.ElementType
const PopoverContentAny = PopoverContent as unknown as React.ElementType
const PopoverBodyAny = PopoverBody as unknown as React.ElementType
const DividerAny = Divider as unknown as React.ElementType
+const TooltipAny = Tooltip as unknown as React.ElementType
function formatRelativeTime(ts: number): string {
const now = Date.now()
@@ -82,12 +79,15 @@ export function TemplatesLibraryView({ onClose }: Props) {
const [creatorFilter, setCreatorFilter] = useState([])
const [pinningId, setPinningId] = useState(null)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
- const [settingsKnownIsPrivate, setSettingsKnownIsPrivate] = useState<
- boolean | null
- >(null)
+ const [settingsRecord, setSettingsRecord] = useState(
+ null,
+ )
const [activeVirtualIndex, setActiveVirtualIndex] = useState(
null,
)
+ // Used to make keyboard navigation deterministic even under batched updates
+ // (e.g. rapid key presses or tight loops in tests).
+ const activeVirtualIndexRef = useRef(null)
const { loadTemplate, resetToNewTemplate, hydrateTemplateMetadata } =
useEditorStore()
@@ -240,19 +240,10 @@ export function TemplatesLibraryView({ onClose }: Props) {
[provider, resetToNewTemplate],
)
- const handleOpenSettings = useCallback(
- (record: TemplateRecord) => {
- loadTemplate(record.transformations)
- hydrateTemplateMetadata({
- templateId: record.id,
- templateName: record.name,
- templateIsPrivate: record.isPrivate,
- })
- setSettingsKnownIsPrivate(record.isPrivate)
- setIsSettingsOpen(true)
- },
- [loadTemplate, hydrateTemplateMetadata],
- )
+ const handleOpenSettings = useCallback((record: TemplateRecord) => {
+ setSettingsRecord(record)
+ setIsSettingsOpen(true)
+ }, [])
const showCurrentRow = shouldShowCurrent && activeTemplate !== null
@@ -290,19 +281,38 @@ export function TemplatesLibraryView({ onClose }: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: reset active row on filter/search changes
useEffect(() => {
setActiveVirtualIndex(null)
+ activeVirtualIndexRef.current = null
}, [search, visibilityFilter, creatorFilter, templates.length, templateId])
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (virtualRowCount === 0) return
+
+ if (e.key === "Enter") {
+ e.preventDefault()
+ const activeIndex = activeVirtualIndexRef.current
+ if (activeIndex === null) return
+
+ const { record } = getRowByVirtualIndex(activeIndex)
+ handleSelect(record)
+ return
+ }
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
e.preventDefault()
- const current = activeVirtualIndex ?? -1
- const next =
- e.key === "ArrowDown"
+ // Avoid wrap-around (cyclic navigation) for very large lists. When the
+ // virtualized list is big, wrapping makes it too easy to "lose" your place.
+ const shouldCycle = virtualRowCount <= 200
+ const current = activeVirtualIndexRef.current ?? -1
+ const next = shouldCycle
+ ? e.key === "ArrowDown"
? (current + 1 + virtualRowCount) % virtualRowCount
: (current - 1 + virtualRowCount) % virtualRowCount
+ : e.key === "ArrowDown"
+ ? Math.min(current + 1, virtualRowCount - 1)
+ : Math.max(current - 1, 0)
+ activeVirtualIndexRef.current = next
setActiveVirtualIndex(next)
rowVirtualizer.scrollToIndex(next, { align: "auto" })
}
@@ -627,7 +637,6 @@ export function TemplatesLibraryView({ onClose }: Props) {
onSettings={handleOpenSettings}
isCurrent={isCurrent}
isActive={isActive}
- canDelete
/>
)
@@ -638,11 +647,25 @@ export function TemplatesLibraryView({ onClose }: Props) {
)}
- {isSettingsOpen && (
+ {isSettingsOpen && settingsRecord && (
setIsSettingsOpen(false)}
+ key={settingsRecord.id}
+ data={settingsRecord}
+ onClose={() => {
+ setIsSettingsOpen(false)
+ setSettingsRecord(null)
+ }}
+ onSaved={(updated) => {
+ // Refresh the template list so the updated name/visibility is reflected
+ setTemplates((prev) =>
+ prev.map((t) => (t.id === updated.id ? updated : t)),
+ )
+ }}
+ onDeleted={() => {
+ setTemplates((prev) =>
+ prev.filter((t) => t.id !== settingsRecord.id),
+ )
+ }}
/>
)}
@@ -658,7 +681,6 @@ interface TemplateRowProps {
isPinning: boolean
isCurrent?: boolean
isActive?: boolean
- canDelete?: boolean
}
function TemplateRow({
@@ -670,13 +692,9 @@ function TemplateRow({
isPinning,
isCurrent = false,
isActive = false,
- canDelete = true,
}: TemplateRowProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const recordNameUI = formatTemplateNameForUI(record.name)
- const MenuButtonAny = MenuButton as unknown as React.ElementType
- const MenuListAny = MenuList as unknown as React.ElementType
- const MenuItemAny = MenuItem as unknown as React.ElementType
return (
- {/* Row actions menu + delete confirmation popup */}
+ {/* Row actions: Settings button + delete confirmation popup */}
setShowDeleteConfirm(false)}
@@ -811,8 +829,8 @@ function TemplateRow({
w="8"
onClick={(e) => e.stopPropagation()}
>
-
-
+ ) =>
+ onClick={(e: React.MouseEvent) => {
e.stopPropagation()
- }
+ onSettings(record)
+ }}
+ aria-label="Template Settings"
>
-
- ) =>
- e.stopPropagation()
- }
- >
- }
- onClick={(e: React.MouseEvent) => {
- e.stopPropagation()
- onSettings(record)
- }}
- >
- Settings
-
- }
- color={canDelete ? "red.500" : "gray.400"}
- display="flex"
- alignItems="center"
- _hover={{ bg: canDelete ? "red.50" : "transparent" }}
- isDisabled={!canDelete}
- onClick={(e: React.MouseEvent) => {
- if (!canDelete) return
- e.stopPropagation()
- setShowDeleteConfirm(true)
- }}
- >
- Delete
-
-
-
+
+
Date: Thu, 30 Apr 2026 02:34:22 +0530
Subject: [PATCH 54/64] fix: more ui fixes - much stable state
---
packages/imagekit-editor-dev/package.json | 2 +-
.../components/header/SettingsModal.test.tsx | 183 ++++++++++++++++++
.../src/components/header/SettingsModal.tsx | 136 ++++++++++++-
.../src/components/header/index.tsx | 12 +-
.../templates/TemplatesLibraryView.test.tsx | 66 +++++++
.../templates/TemplatesLibraryView.tsx | 8 +-
packages/imagekit-editor/package.json | 2 +-
7 files changed, 398 insertions(+), 11 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index 772747e..cc2f6e5 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-dev-29-04-2026.10",
+ "version": "2.2.0-dev-29-04-2026.14",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
new file mode 100644
index 0000000..4ea60d2
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
@@ -0,0 +1,183 @@
+import { ChakraProvider } from "@chakra-ui/react"
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext"
+import type { TemplateRecord } from "../../storage"
+import { useEditorStore } from "../../store"
+import { SettingsModal } from "./SettingsModal"
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeTemplate(partial: Partial = {}): TemplateRecord {
+ const now = Date.now()
+ return {
+ id: "t-1",
+ clientNumber: "c1",
+ isPrivate: false,
+ name: "My Template",
+ transformations: [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "Creator", email: "creator@example.com" },
+ updatedBy: { userId: "u1", name: "Creator", email: "creator@example.com" },
+ createdAt: now,
+ updatedAt: now,
+ ...partial,
+ }
+}
+
+function renderModal(opts: {
+ data?: TemplateRecord
+ onClose?: () => void
+ onSaved?: (r: TemplateRecord) => void
+ onDeleted?: () => void
+ deleteTemplate?: ((id: string) => Promise) | undefined
+ // biome-ignore lint/suspicious/noExplicitAny: test stub
+ saveTemplate?: (r: any) => Promise
+}) {
+ const data = opts.data ?? makeTemplate()
+
+ const provider = {
+ getProviderName: () => "test",
+ getCurrentUserSession: () => ({ id: "u1" }),
+ listTemplates: async () => [],
+ getTemplate: async () => data,
+ // biome-ignore lint/suspicious/noExplicitAny: test stub
+ saveTemplate:
+ opts.saveTemplate ?? (async (_r: any) => makeTemplate({ id: "saved" })),
+ setTemplatePinned: vi.fn(),
+ ...(opts.deleteTemplate !== undefined
+ ? { deleteTemplate: opts.deleteTemplate }
+ : {}),
+ }
+
+ return render(
+
+ {/* biome-ignore lint/suspicious/noExplicitAny: test stub */}
+
+
+
+ ,
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("SettingsModal", () => {
+ beforeEach(() => {
+ useEditorStore.getState().destroy()
+ })
+
+ describe("delete confirmation flow", () => {
+ it("does NOT call deleteTemplate immediately when Delete is clicked", async () => {
+ const deleteTemplate = vi.fn(async () => {})
+ renderModal({ deleteTemplate })
+
+ expect(await screen.findByText("Template Settings")).toBeTruthy()
+
+ act(() => {
+ fireEvent.click(screen.getByText("Delete"))
+ })
+
+ // deleteTemplate must NOT have been called yet
+ expect(deleteTemplate).not.toHaveBeenCalled()
+ })
+
+ it("shows the confirmation panel after clicking Delete", async () => {
+ renderModal({ deleteTemplate: vi.fn(async () => {}) })
+
+ expect(await screen.findByText("Template Settings")).toBeTruthy()
+
+ act(() => {
+ fireEvent.click(screen.getByText("Delete"))
+ })
+
+ // Confirmation panel should now be visible
+ expect(screen.getByText("Delete template?")).toBeTruthy()
+ expect(screen.getByText(/This action cannot be reversed/)).toBeTruthy()
+ expect(screen.getByText("Yes, delete")).toBeTruthy()
+ })
+
+ it("does NOT call deleteTemplate when confirmation is dismissed with Cancel", async () => {
+ const deleteTemplate = vi.fn(async () => {})
+ renderModal({ deleteTemplate })
+
+ expect(await screen.findByText("Template Settings")).toBeTruthy()
+
+ // Open confirmation panel
+ act(() => {
+ fireEvent.click(screen.getByText("Delete"))
+ })
+
+ expect(screen.getByText("Delete template?")).toBeTruthy()
+
+ // Dismiss without confirming — click the Cancel inside the confirmation panel
+ act(() => {
+ fireEvent.click(screen.getByTestId("delete-confirm-cancel"))
+ })
+
+ // Panel should be gone
+ expect(screen.queryByText("Delete template?")).toBeNull()
+ // deleteTemplate still must not have been called
+ expect(deleteTemplate).not.toHaveBeenCalled()
+ })
+
+ it("calls deleteTemplate only after clicking Yes, delete", async () => {
+ const deleteTemplate = vi.fn(async () => {})
+ const onDeleted = vi.fn()
+ const onClose = vi.fn()
+
+ renderModal({ deleteTemplate, onDeleted, onClose })
+
+ expect(await screen.findByText("Template Settings")).toBeTruthy()
+
+ // Open confirmation panel
+ act(() => {
+ fireEvent.click(screen.getByText("Delete"))
+ })
+
+ expect(screen.getByTestId("delete-confirm-submit")).toBeTruthy()
+
+ // Confirm deletion
+ act(() => {
+ fireEvent.click(screen.getByTestId("delete-confirm-submit"))
+ })
+
+ await waitFor(() => {
+ expect(deleteTemplate).toHaveBeenCalledTimes(1)
+ expect(deleteTemplate).toHaveBeenCalledWith("t-1")
+ })
+
+ expect(onDeleted).toHaveBeenCalledTimes(1)
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it("Delete button is disabled while confirmation panel is open", async () => {
+ renderModal({ deleteTemplate: vi.fn(async () => {}) })
+
+ expect(await screen.findByText("Template Settings")).toBeTruthy()
+
+ // Before opening: Delete button is NOT disabled
+ const deleteBtn = screen.getByText("Delete")
+ expect(deleteBtn.closest("[aria-disabled='true']")).toBeNull()
+
+ // Open confirmation panel
+ act(() => {
+ fireEvent.click(deleteBtn)
+ })
+
+ // After opening: the footer Delete button should be aria-disabled="true"
+ const deleteBtnsAfter = screen.getAllByText("Delete")
+ const footerDelete = deleteBtnsAfter[deleteBtnsAfter.length - 1]
+ expect(footerDelete.closest("[aria-disabled='true']")).not.toBeNull()
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 5eafbb9..eb27631 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -78,6 +78,7 @@ export function SettingsModal({
const [isDeleting, setIsDeleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Fetch authoritative visibility + creator check from the API.
useEffect(() => {
@@ -159,10 +160,11 @@ export function SettingsModal({
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------
- const handleDelete = async () => {
+ const handleDeleteConfirmed = async () => {
if (!provider || !provider.deleteTemplate) return
setIsDeleting(true)
+ setShowDeleteConfirm(false)
try {
await provider.deleteTemplate(data.id)
onDeleted?.()
@@ -222,10 +224,10 @@ export function SettingsModal({
maxH="50vh"
bg="white"
borderRadius="xl"
- overflow="hidden"
boxShadow="xl"
display="flex"
flexDirection="column"
+ position="relative"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
@@ -317,6 +319,116 @@ export function SettingsModal({
+ {/* Delete confirmation — blur overlay + centered floating card */}
+ {showDeleteConfirm && (
+ <>
+ {/* Frosted-glass overlay dims the modal content behind the popover */}
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+ Delete template?
+
+
+ This will permanently delete “
+ {formatTemplateNameForUI(data.name)}”. This action
+ cannot be reversed.
+
+
+
+
+ setShowDeleteConfirm(false)}
+ data-testid="delete-confirm-cancel"
+ >
+ Cancel
+
+
+ {isDeleting ? "Deleting…" : "Yes, delete"}
+
+
+
+
+ >
+ )}
+
{/* Footer */}
setShowDeleteConfirm(true)
+ }
+ aria-disabled={isDeleting || isSaving || showDeleteConfirm}
>
{isDeleting ? "Deleting…" : "Delete"}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 5a95ce0..9447873 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -67,6 +67,7 @@ export const Header = ({
}: HeaderProps) => {
const { imageList, originalImageList, currentImage } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
+ const templateIsPrivate = useEditorStore((s) => s.templateIsPrivate)
const syncStatus = useEditorStore((s) => s.syncStatus)
const provider = useTemplateStorage()
@@ -119,7 +120,16 @@ export const Header = ({
{templateId && (
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
index 860a7e3..b257301 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.test.tsx
@@ -151,6 +151,72 @@ describe("TemplatesLibraryView (virtualized)", () => {
vi.useRealTimers()
})
+ it("applies visibility filter consistently when both options are selected", async () => {
+ const templates: TemplateRecord[] = [
+ makeTemplate({
+ id: "t-private",
+ name: "Private template",
+ isPrivate: true,
+ updatedAt: Date.now(),
+ createdAt: Date.now(),
+ }),
+ makeTemplate({
+ id: "t-shared",
+ name: "Shared template",
+ isPrivate: false,
+ updatedAt: Date.now() - 1,
+ createdAt: Date.now() - 1,
+ }),
+ ]
+
+ useEditorStore.setState({
+ isPristine: true,
+ templateId: null,
+ templateName: "New template",
+ transformations: [],
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider({ templates })
+
+ expect(await screen.findByText("All templates")).toBeTruthy()
+
+ // Baseline: both templates are present.
+ expect(
+ await screen.findByTestId("templates-library-row-t-private"),
+ ).toBeTruthy()
+ expect(
+ await screen.findByTestId("templates-library-row-t-shared"),
+ ).toBeTruthy()
+
+ // Click each chip in a separate act() so React re-renders between clicks.
+ // Batching both inside one act() causes the second click to read stale
+ // visibilityFilter state (safeValue is captured at render time), making
+ // the final value ["shared"] instead of ["private","shared"].
+ const clickChip = (label: string) => {
+ const candidates = screen.getAllByText(label)
+ const chip = candidates
+ .map((n) => n.closest("[aria-checked]"))
+ .find((el): el is HTMLElement => Boolean(el))
+ if (!chip) throw new Error(`Filter chip not found for label: ${label}`)
+ fireEvent.click(chip)
+ }
+
+ act(() => {
+ clickChip("Only to me")
+ })
+ act(() => {
+ clickChip("Shared with everyone")
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId("templates-library-row-t-private")).toBeTruthy()
+ expect(screen.getByTestId("templates-library-row-t-shared")).toBeTruthy()
+ })
+ })
+
it(
"does not render thousands of rows at once; renders more on scroll",
async () => {
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 97963bb..0d14423 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -160,8 +160,12 @@ export function TemplatesLibraryView({ onClose }: Props) {
)
.filter((t) => {
if (visibilityFilter.length === 0) return true
- if (visibilityFilter.includes("private")) return t.isPrivate
- if (visibilityFilter.includes("shared")) return !t.isPrivate
+ const allowPrivate = visibilityFilter.includes("private")
+ const allowShared = visibilityFilter.includes("shared")
+ // If both are selected, visibility is effectively unfiltered (OR across both buckets).
+ if (allowPrivate && allowShared) return true
+ if (allowPrivate) return t.isPrivate
+ if (allowShared) return !t.isPrivate
return true
})
.filter((t) =>
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 1963d89..3c60690 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-dev-29-04-2026.10",
+ "version": "2.2.0-dev-29-04-2026.14",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 52b15184ecdc791b21f5e9786910150904f9db13 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 02:47:37 +0530
Subject: [PATCH 55/64] fix: final set of ui fixes - very stable build
---
packages/imagekit-editor-dev/package.json | 2 +-
.../components/header/TemplatesDropdown.tsx | 226 +++++++++++-------
packages/imagekit-editor/package.json | 2 +-
3 files changed, 145 insertions(+), 85 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index cc2f6e5..a50c766 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-dev-29-04-2026.14",
+ "version": "2.2.0-stage-30-04-2026.1",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index b884b7d..c43ed6d 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -1,10 +1,4 @@
import {
- AlertDialog,
- AlertDialogBody,
- AlertDialogContent,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogOverlay,
Avatar,
Badge,
Box,
@@ -48,19 +42,12 @@ const PopoverBodyAny = PopoverBody as unknown as React.FC<
Record
>
-const AlertDialogContentAny = AlertDialogContent as unknown as React.FC<
- Record
->
-
const BoxAny = Box as unknown as React.FC>
const TextAny = Text as unknown as React.ElementType
const FlexAny = Flex as unknown as React.ElementType
const DividerAny = Divider as unknown as React.ElementType
const ButtonAny = Button as unknown as React.ElementType
const IconAny = Icon as unknown as React.ElementType
-const AlertDialogHeaderAny = AlertDialogHeader as unknown as React.ElementType
-const AlertDialogBodyAny = AlertDialogBody as unknown as React.ElementType
-const AlertDialogFooterAny = AlertDialogFooter as unknown as React.ElementType
const InputGroupAny = InputGroup as unknown as React.ElementType
const InputLeftElementAny = InputLeftElement as unknown as React.ElementType
const InputAny = Input as unknown as React.ElementType
@@ -88,7 +75,7 @@ export function TemplatesDropdown({
)
const searchRef = useRef(null)
const resultsScrollRef = useRef(null)
- const cancelRef = useRef(null)
+
const [isSavingAndContinuing, setIsSavingAndContinuing] = useState(false)
const [pendingTemplate, setPendingTemplate] = useState(
@@ -651,91 +638,164 @@ export function TemplatesDropdown({
- {
- if (isSavingAndContinuing) return
- setPendingTemplate(null)
- }}
- isCentered
- >
-
- {
+ if (isSavingAndContinuing) return
+ setPendingTemplate(null)
+ }}
+ >
+ e.stopPropagation()}
>
-
- Unsaved changes
-
-
- Your current changes haven't been saved yet. What would you like
- to do before switching to{" "}
-
- {pendingTemplate
- ? formatTemplateNameForUI(pendingTemplate.name)
- : null}
-
- ?
-
-
+ Unsaved changes
+
+
+
+ {/* Body */}
+
+
+ Your current changes haven't been saved yet. What would you
+ like to do before switching to{" "}
+
+ {formatTemplateNameForUI(pendingTemplate.name)}
+
+ ?
+
+
+
+ {/* Footer */}
+
- setPendingTemplate(null)}
- isDisabled={isSavingAndContinuing}
+ {/* Cancel */}
+ setPendingTemplate(null)
+ }
+ aria-disabled={isSavingAndContinuing}
>
Cancel
-
-
+
+ {/* Continue without saving */}
+
Continue without saving
-
-
+
+ {/* Save and continue */}
+
- Save and continue
-
-
-
-
-
+ {isSavingAndContinuing && (
+
+ )}
+ {isSavingAndContinuing ? "Saving…" : "Save and continue"}
+
+
+
+
+ )}
>
)
}
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 3c60690..6ed7300 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-dev-29-04-2026.14",
+ "version": "2.2.0-stage-30-04-2026.1",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From c144f9e335bc582d3ea669471b92b1ce82a9ebee Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 02:57:11 +0530
Subject: [PATCH 56/64] fix: move to chakraAny
---
.../src/components/header/SettingsModal.tsx | 9 +++--
.../components/header/TemplateNameInput.tsx | 3 +-
.../src/components/header/TemplateStatus.tsx | 18 ++++-----
.../components/header/TemplatesDropdown.tsx | 37 ++++++++-----------
.../templates/TemplatesLibraryView.tsx | 29 ++++++++-------
.../src/utils/chakraAny.ts | 11 ++++++
6 files changed, 56 insertions(+), 51 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/utils/chakraAny.ts
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index eb27631..9b81676 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -11,14 +11,15 @@ import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessE
import type { TemplateRecord } from "../../storage/types"
import { useEditorStore } from "../../store"
import { formatTemplateNameForUI } from "../../utils"
+import { chakraAny } from "../../utils/chakraAny"
// ---------------------------------------------------------------------------
// Type casts — Chakra's strict generic signatures conflict with our JSX usage
// ---------------------------------------------------------------------------
-const FlexAny = Flex as unknown as React.ElementType
-const TextAny = Text as unknown as React.ElementType
-const IconButtonAny = IconButton as unknown as React.ElementType
-const InputAny = Input as unknown as React.ElementType
+const FlexAny = chakraAny(Flex)
+const TextAny = chakraAny(Text)
+const IconButtonAny = chakraAny(IconButton)
+const InputAny = chakraAny(Input)
// ---------------------------------------------------------------------------
// Props
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
index e6cdc1e..1e31520 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -3,9 +3,10 @@ import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useEditorStore } from "../../store"
import { formatTemplateNameForUI } from "../../utils"
+import { chakraAny } from "../../utils/chakraAny"
const UNTITLED = "Untitled Template"
-const InputAny = Input as unknown as React.ElementType
+const InputAny = chakraAny(Input)
export function TemplateNameInput() {
const templateNameRaw = useEditorStore((s) => s.templateName)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 7aff193..49117da 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -14,24 +14,20 @@ import {
import { IoMdCloudDone } from "@react-icons/all-files/io/IoMdCloudDone"
import { MdSync } from "@react-icons/all-files/md/MdSync"
import { MdSyncProblem } from "@react-icons/all-files/md/MdSyncProblem"
-import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { useEditorStore } from "../../store"
+import { chakraAny } from "../../utils/chakraAny"
const NOTIFICATION_DURATION_MS = 3000
-const TextAny = Text as unknown as React.FC>
-const TextAny2 = Text as unknown as React.FC>
-const FlexAny = Flex as unknown as React.FC>
-const PopoverContentAny = PopoverContent as unknown as React.FC<
- Record
->
-const TooltipAny = Tooltip as unknown as React.FC>
-const PopoverBodyAny = PopoverBody as unknown as React.FC<
- Record
->
+const TextAny = chakraAny(Text)
+const TextAny2 = chakraAny(Text)
+const FlexAny = chakraAny(Flex)
+const PopoverContentAny = chakraAny(PopoverContent)
+const TooltipAny = chakraAny(Tooltip)
+const PopoverBodyAny = chakraAny(PopoverBody)
export function TemplateStatus() {
const syncStatus = useEditorStore((s) => s.syncStatus)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index c43ed6d..d151d41 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -33,27 +33,22 @@ import type { TemplateRecord } from "../../storage"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
import { formatTemplateNameForUI, truncateTemplateName } from "../../utils"
-
-const PopoverContentAny = PopoverContent as unknown as React.FC<
- Record
->
-
-const PopoverBodyAny = PopoverBody as unknown as React.FC<
- Record
->
-
-const BoxAny = Box as unknown as React.FC>
-const TextAny = Text as unknown as React.ElementType
-const FlexAny = Flex as unknown as React.ElementType
-const DividerAny = Divider as unknown as React.ElementType
-const ButtonAny = Button as unknown as React.ElementType
-const IconAny = Icon as unknown as React.ElementType
-const InputGroupAny = InputGroup as unknown as React.ElementType
-const InputLeftElementAny = InputLeftElement as unknown as React.ElementType
-const InputAny = Input as unknown as React.ElementType
-const BadgeAny = Badge as unknown as React.ElementType
-const AvatarAny = Avatar as unknown as React.ElementType
-const SpinnerAny = Spinner as unknown as React.ElementType
+import { chakraAny } from "../../utils/chakraAny"
+
+const PopoverContentAny = chakraAny(PopoverContent)
+const PopoverBodyAny = chakraAny(PopoverBody)
+const BoxAny = chakraAny(Box)
+const TextAny = chakraAny(Text)
+const FlexAny = chakraAny(Flex)
+const DividerAny = chakraAny(Divider)
+const ButtonAny = chakraAny(Button)
+const IconAny = chakraAny(Icon)
+const InputGroupAny = chakraAny(InputGroup)
+const InputLeftElementAny = chakraAny(InputLeftElement)
+const InputAny = chakraAny(Input)
+const BadgeAny = chakraAny(Badge)
+const AvatarAny = chakraAny(Avatar)
+const SpinnerAny = chakraAny(Spinner)
const MAX_VISIBLE = 5
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 0d14423..dd902c8 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -36,6 +36,7 @@ import { useDebounce } from "../../hooks/useDebounce"
import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
import { formatTemplateNameForUI, truncateTemplateName } from "../../utils"
+import { chakraAny } from "../../utils/chakraAny"
import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
import { SettingsModal } from "../header/SettingsModal"
@@ -44,20 +45,20 @@ interface Props {
onClose(): void
}
-const FlexAny = Flex as unknown as React.ElementType
-const TextAny = Text as unknown as React.ElementType
-const AvatarAny = Avatar as unknown as React.ElementType
-const ButtonAny = Button as unknown as React.ElementType
-const SpinnerAny = Spinner as unknown as React.ElementType
-const BadgeAny = Badge as unknown as React.ElementType
-const InputGroupAny = InputGroup as unknown as React.ElementType
-const InputLeftElementAny = InputLeftElement as unknown as React.ElementType
-const InputAny = Input as unknown as React.ElementType
-const IconAny = Icon as unknown as React.ElementType
-const PopoverContentAny = PopoverContent as unknown as React.ElementType
-const PopoverBodyAny = PopoverBody as unknown as React.ElementType
-const DividerAny = Divider as unknown as React.ElementType
-const TooltipAny = Tooltip as unknown as React.ElementType
+const FlexAny = chakraAny(Flex)
+const TextAny = chakraAny(Text)
+const AvatarAny = chakraAny(Avatar)
+const ButtonAny = chakraAny(Button)
+const SpinnerAny = chakraAny(Spinner)
+const BadgeAny = chakraAny(Badge)
+const InputGroupAny = chakraAny(InputGroup)
+const InputLeftElementAny = chakraAny(InputLeftElement)
+const InputAny = chakraAny(Input)
+const IconAny = chakraAny(Icon)
+const PopoverContentAny = chakraAny(PopoverContent)
+const PopoverBodyAny = chakraAny(PopoverBody)
+const DividerAny = chakraAny(Divider)
+const TooltipAny = chakraAny(Tooltip)
function formatRelativeTime(ts: number): string {
const now = Date.now()
diff --git a/packages/imagekit-editor-dev/src/utils/chakraAny.ts b/packages/imagekit-editor-dev/src/utils/chakraAny.ts
new file mode 100644
index 0000000..4464f3c
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/utils/chakraAny.ts
@@ -0,0 +1,11 @@
+import type React from "react"
+
+/**
+ * Chakra components sometimes have strict generic/polymorphic typings that can
+ * get in the way of our JSX usage (especially `as="button"` and certain prop
+ * combinations). This helper centralizes the escape hatch so individual
+ * components don't need repeated `as unknown as React.ElementType` boilerplate.
+ */
+export function chakraAny(component: unknown): React.ElementType {
+ return component as unknown as React.ElementType
+}
From 157e18e15e73e5eb96d73cdc8ae66008bcf24ddc Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 03:12:46 +0530
Subject: [PATCH 57/64] refac: consolidated the template ordering logic used
across template dropdown and library
---
packages/imagekit-editor-dev/package.json | 2 +-
.../src/components/header/SettingsModal.tsx | 3 +-
.../components/header/TemplateNameInput.tsx | 3 +-
.../src/components/header/TemplateStatus.tsx | 2 +-
.../components/header/TemplatesDropdown.tsx | 46 ++---
.../templates/TemplatesLibraryView.tsx | 50 ++---
.../imagekit-editor-dev/src/utils/index.ts | 7 +
.../src/utils/templateList.test.ts | 184 ++++++++++++++++++
.../src/utils/templateList.ts | 80 ++++++++
packages/imagekit-editor/package.json | 2 +-
10 files changed, 307 insertions(+), 72 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/utils/templateList.test.ts
create mode 100644 packages/imagekit-editor-dev/src/utils/templateList.ts
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index a50c766..0c89a7f 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.1",
+ "version": "2.2.0-stage-30-04-2026.2",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 9b81676..962da82 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -10,8 +10,7 @@ import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import type { TemplateRecord } from "../../storage/types"
import { useEditorStore } from "../../store"
-import { formatTemplateNameForUI } from "../../utils"
-import { chakraAny } from "../../utils/chakraAny"
+import { chakraAny, formatTemplateNameForUI } from "../../utils"
// ---------------------------------------------------------------------------
// Type casts — Chakra's strict generic signatures conflict with our JSX usage
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
index 1e31520..c17952f 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateNameInput.tsx
@@ -2,8 +2,7 @@ import { Input } from "@chakra-ui/react"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useEditorStore } from "../../store"
-import { formatTemplateNameForUI } from "../../utils"
-import { chakraAny } from "../../utils/chakraAny"
+import { chakraAny, formatTemplateNameForUI } from "../../utils"
const UNTITLED = "Untitled Template"
const InputAny = chakraAny(Input)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 49117da..4f4f359 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -18,7 +18,7 @@ import { useEffect, useRef, useState } from "react"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useSaveTemplate } from "../../hooks/useSaveTemplate"
import { useEditorStore } from "../../store"
-import { chakraAny } from "../../utils/chakraAny"
+import { chakraAny } from "../../utils"
const NOTIFICATION_DURATION_MS = 3000
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index d151d41..2f27492 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -32,8 +32,12 @@ import { useTemplateSync } from "../../hooks/useTemplateSync"
import type { TemplateRecord } from "../../storage"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
-import { formatTemplateNameForUI, truncateTemplateName } from "../../utils"
-import { chakraAny } from "../../utils/chakraAny"
+import {
+ chakraAny,
+ formatTemplateNameForUI,
+ getDisplayTemplates,
+ truncateTemplateName,
+} from "../../utils"
const PopoverContentAny = chakraAny(PopoverContent)
const PopoverBodyAny = chakraAny(PopoverBody)
@@ -128,35 +132,15 @@ export function TemplatesDropdown({
: transformations.length
const filtered = useMemo(() => {
- const base = templates
- .filter((t) => t.id !== templateId)
- .filter((t) => {
- if (
- shouldShowCurrent &&
- templateId === null &&
- t.name === templateName
- ) {
- return false
- }
- return true
- })
- .filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
-
- // Sort by: pinned first, then by most recently used/updated
- return [...base]
- .sort((a, b) => {
- const aPinned = a.isPinned ? 1 : 0
- const bPinned = b.isPinned ? 1 : 0
- if (aPinned !== bPinned) {
- return bPinned - aPinned
- }
-
- const aTime = a.lastUsedAt ?? a.updatedAt
- const bTime = b.lastUsedAt ?? b.updatedAt
- return bTime - aTime
- })
- .slice(0, MAX_VISIBLE)
- }, [templates, templateId, search, shouldShowCurrent, templateName])
+ return getDisplayTemplates({
+ templates,
+ templateId,
+ templateName,
+ shouldShowCurrent,
+ search,
+ searchMode: "name",
+ }).slice(0, MAX_VISIBLE)
+ }, [templates, templateId, templateName, shouldShowCurrent, search])
useEffect(() => {
if (!isOpen) return
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index dd902c8..23e9190 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -35,8 +35,12 @@ import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useDebounce } from "../../hooks/useDebounce"
import type { TemplateRecord } from "../../storage"
import { useEditorStore } from "../../store"
-import { formatTemplateNameForUI, truncateTemplateName } from "../../utils"
-import { chakraAny } from "../../utils/chakraAny"
+import {
+ chakraAny,
+ formatTemplateNameForUI,
+ getDisplayTemplates,
+ truncateTemplateName,
+} from "../../utils"
import FilterChipsField from "../common/FilterChipsField"
import MultiSelectListField from "../common/MultiSelectListField"
import { SettingsModal } from "../header/SettingsModal"
@@ -140,25 +144,14 @@ export function TemplatesLibraryView({ onClose }: Props) {
}, [templates])
const filtered = useMemo(() => {
- const base = templates
- .filter((t) => t.id !== templateId)
- .filter((t) => {
- if (
- shouldShowCurrent &&
- templateId === null &&
- t.name === templateName
- ) {
- return false
- }
- return true
- })
- .filter((t) =>
- search
- ? t.name.toLowerCase().includes(search.toLowerCase()) ||
- t.createdBy.name.toLowerCase().includes(search.toLowerCase()) ||
- t.createdBy.email.toLowerCase().includes(search.toLowerCase())
- : true,
- )
+ const base = getDisplayTemplates({
+ templates,
+ templateId,
+ templateName,
+ shouldShowCurrent,
+ search,
+ searchMode: "nameOrCreator",
+ })
.filter((t) => {
if (visibilityFilter.length === 0) return true
const allowPrivate = visibilityFilter.includes("private")
@@ -175,19 +168,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
: true,
)
- // Sort so that pinned templates (for the local user) come first,
- // then all others by most recently used / updated.
- return [...base].sort((a, b) => {
- const aPinned = a.isPinned ? 1 : 0
- const bPinned = b.isPinned ? 1 : 0
- if (aPinned !== bPinned) {
- return bPinned - aPinned
- }
-
- const aTime = a.lastUsedAt ?? a.updatedAt
- const bTime = b.lastUsedAt ?? b.updatedAt
- return bTime - aTime
- })
+ // getDisplayTemplates already returns a pinned+recent sorted list.
+ return base
}, [
templates,
templateId,
diff --git a/packages/imagekit-editor-dev/src/utils/index.ts b/packages/imagekit-editor-dev/src/utils/index.ts
index 2d284da..2f0f1af 100644
--- a/packages/imagekit-editor-dev/src/utils/index.ts
+++ b/packages/imagekit-editor-dev/src/utils/index.ts
@@ -150,3 +150,10 @@ export const truncateTemplateName = (name: string) => {
}
return `${normalized.slice(0, TEMPLATE_NAME_UI_MAX_LENGTH)}...`
}
+
+export { chakraAny } from "./chakraAny"
+export {
+ getDisplayTemplates,
+ shouldHideTemplateBecauseMatchesUnsavedCurrent,
+ sortTemplatesPinnedThenRecent,
+} from "./templateList"
diff --git a/packages/imagekit-editor-dev/src/utils/templateList.test.ts b/packages/imagekit-editor-dev/src/utils/templateList.test.ts
new file mode 100644
index 0000000..6e4af84
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/utils/templateList.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from "vitest"
+import type { TemplateRecord } from "../storage"
+import {
+ getDisplayTemplates,
+ shouldHideTemplateBecauseMatchesUnsavedCurrent,
+ sortTemplatesPinnedThenRecent,
+} from "./templateList"
+
+function makeTemplate(partial: Partial): TemplateRecord {
+ const now = 1_000_000
+ return {
+ id: "t-1",
+ clientNumber: "c1",
+ isPrivate: true,
+ name: "Template 1",
+ transformations: [],
+ isPinned: false,
+ createdBy: { userId: "u1", name: "Creator", email: "c@example.com" },
+ updatedBy: { userId: "u1", name: "Creator", email: "c@example.com" },
+ createdAt: now,
+ updatedAt: now,
+ ...partial,
+ }
+}
+
+describe("templateList utilities", () => {
+ describe("shouldHideTemplateBecauseMatchesUnsavedCurrent", () => {
+ it("hides a record with same name when current is unsaved (templateId null) and Current row is shown", () => {
+ const record = makeTemplate({ id: "saved", name: "In progress" })
+ expect(
+ shouldHideTemplateBecauseMatchesUnsavedCurrent({
+ record,
+ templateId: null,
+ shouldShowCurrent: true,
+ templateName: "In progress",
+ }),
+ ).toBe(true)
+ })
+
+ it("does not hide when Current row is not shown", () => {
+ const record = makeTemplate({ id: "saved", name: "In progress" })
+ expect(
+ shouldHideTemplateBecauseMatchesUnsavedCurrent({
+ record,
+ templateId: null,
+ shouldShowCurrent: false,
+ templateName: "In progress",
+ }),
+ ).toBe(false)
+ })
+
+ it("does not hide when a saved template is active (templateId not null)", () => {
+ const record = makeTemplate({ id: "saved", name: "Same" })
+ expect(
+ shouldHideTemplateBecauseMatchesUnsavedCurrent({
+ record,
+ templateId: "active-id",
+ shouldShowCurrent: true,
+ templateName: "Same",
+ }),
+ ).toBe(false)
+ })
+ })
+
+ describe("sortTemplatesPinnedThenRecent", () => {
+ it("sorts pinned before unpinned", () => {
+ const a = makeTemplate({ id: "a", isPinned: false, updatedAt: 5 })
+ const b = makeTemplate({ id: "b", isPinned: true, updatedAt: 1 })
+ const sorted = [a, b].sort(sortTemplatesPinnedThenRecent)
+ expect(sorted.map((t) => t.id)).toEqual(["b", "a"])
+ })
+
+ it("sorts by lastUsedAt when present, else updatedAt", () => {
+ const olderButUsed = makeTemplate({
+ id: "used",
+ isPinned: false,
+ updatedAt: 10,
+ lastUsedAt: 200,
+ })
+ const newerButNotUsed = makeTemplate({
+ id: "updated",
+ isPinned: false,
+ updatedAt: 300,
+ lastUsedAt: undefined,
+ })
+ const sorted = [olderButUsed, newerButNotUsed].sort(
+ sortTemplatesPinnedThenRecent,
+ )
+ expect(sorted.map((t) => t.id)).toEqual(["updated", "used"])
+ })
+ })
+
+ describe("getDisplayTemplates", () => {
+ it("excludes the active template by id", () => {
+ const t1 = makeTemplate({ id: "t1", name: "One", updatedAt: 1 })
+ const t2 = makeTemplate({ id: "t2", name: "Two", updatedAt: 2 })
+ const list = getDisplayTemplates({
+ templates: [t1, t2],
+ templateId: "t2",
+ templateName: "Two",
+ shouldShowCurrent: true,
+ search: "",
+ })
+ expect(list.map((t) => t.id)).toEqual(["t1"])
+ })
+
+ it("hides the saved record that matches unsaved current name when templateId is null", () => {
+ const savedSameName = makeTemplate({
+ id: "saved",
+ name: "In progress",
+ updatedAt: 2,
+ })
+ const other = makeTemplate({ id: "other", name: "Other", updatedAt: 1 })
+ const list = getDisplayTemplates({
+ templates: [savedSameName, other],
+ templateId: null,
+ templateName: "In progress",
+ shouldShowCurrent: true,
+ search: "",
+ })
+ expect(list.map((t) => t.id)).toEqual(["other"])
+ })
+
+ it("filters by name search (case-insensitive) by default", () => {
+ const alpha = makeTemplate({ id: "a", name: "Alpha", updatedAt: 1 })
+ const beta = makeTemplate({ id: "b", name: "Beta", updatedAt: 2 })
+ const list = getDisplayTemplates({
+ templates: [alpha, beta],
+ templateId: null,
+ templateName: "New",
+ shouldShowCurrent: false,
+ search: "alP",
+ })
+ expect(list.map((t) => t.id)).toEqual(["a"])
+ })
+
+ it('supports searchMode "nameOrCreator"', () => {
+ const byCreator = makeTemplate({
+ id: "c",
+ name: "Unrelated",
+ createdBy: { userId: "u2", name: "Ada Lovelace", email: "ada@ex.com" },
+ updatedAt: 1,
+ })
+ const other = makeTemplate({
+ id: "o",
+ name: "Other",
+ createdBy: { userId: "u3", name: "Grace", email: "g@ex.com" },
+ updatedAt: 2,
+ })
+ const list = getDisplayTemplates({
+ templates: [byCreator, other],
+ templateId: null,
+ templateName: "New",
+ shouldShowCurrent: false,
+ search: "ada",
+ searchMode: "nameOrCreator",
+ })
+ expect(list.map((t) => t.id)).toEqual(["c"])
+ })
+
+ it("returns pinned+recent sorted results", () => {
+ const pinnedOld = makeTemplate({
+ id: "p",
+ name: "Pinned",
+ isPinned: true,
+ updatedAt: 1,
+ })
+ const unpinnedNew = makeTemplate({
+ id: "u",
+ name: "Unpinned",
+ isPinned: false,
+ updatedAt: 999,
+ })
+ const list = getDisplayTemplates({
+ templates: [unpinnedNew, pinnedOld],
+ templateId: null,
+ templateName: "New",
+ shouldShowCurrent: false,
+ search: "",
+ })
+ expect(list.map((t) => t.id)).toEqual(["p", "u"])
+ })
+ })
+})
diff --git a/packages/imagekit-editor-dev/src/utils/templateList.ts b/packages/imagekit-editor-dev/src/utils/templateList.ts
new file mode 100644
index 0000000..02d61e6
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/utils/templateList.ts
@@ -0,0 +1,80 @@
+import type { TemplateRecord } from "../storage"
+
+export function sortTemplatesPinnedThenRecent(
+ a: TemplateRecord,
+ b: TemplateRecord,
+): number {
+ const aPinned = a.isPinned ? 1 : 0
+ const bPinned = b.isPinned ? 1 : 0
+ if (aPinned !== bPinned) return bPinned - aPinned
+
+ const aTime = a.lastUsedAt ?? a.updatedAt
+ const bTime = b.lastUsedAt ?? b.updatedAt
+ return bTime - aTime
+}
+
+export function shouldHideTemplateBecauseMatchesUnsavedCurrent(args: {
+ record: TemplateRecord
+ /** Current template id in the editor store. */
+ templateId: string | null
+ /** Whether the UI should show the "Current" row (i.e. editor has live state). */
+ shouldShowCurrent: boolean
+ /** Current template name in the editor store. */
+ templateName: string
+}): boolean {
+ const { record, templateId, shouldShowCurrent, templateName } = args
+ return (
+ shouldShowCurrent && templateId === null && record.name === templateName
+ )
+}
+
+type SearchMode = "name" | "nameOrCreator"
+
+function matchesSearch(
+ record: TemplateRecord,
+ searchLower: string,
+ mode: SearchMode,
+): boolean {
+ if (!searchLower) return true
+ if (record.name.toLowerCase().includes(searchLower)) return true
+ if (mode === "name") return false
+ return (
+ record.createdBy.name.toLowerCase().includes(searchLower) ||
+ record.createdBy.email.toLowerCase().includes(searchLower)
+ )
+}
+
+export function getDisplayTemplates(args: {
+ templates: TemplateRecord[]
+ templateId: string | null
+ templateName: string
+ shouldShowCurrent: boolean
+ search: string
+ searchMode?: SearchMode
+}): TemplateRecord[] {
+ const {
+ templates,
+ templateId,
+ templateName,
+ shouldShowCurrent,
+ search,
+ searchMode = "name",
+ } = args
+
+ const searchLower = search.toLowerCase()
+
+ return templates
+ .filter((t) => t.id !== templateId)
+ .filter(
+ (t) =>
+ !shouldHideTemplateBecauseMatchesUnsavedCurrent({
+ record: t,
+ templateId,
+ shouldShowCurrent,
+ templateName,
+ }),
+ )
+ .filter((t) => matchesSearch(t, searchLower, searchMode))
+ .slice()
+ .sort(sortTemplatesPinnedThenRecent)
+}
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 6ed7300..e6e4f9b 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.1",
+ "version": "2.2.0-stage-30-04-2026.2",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 333031ceb41de31e5a978055c237bffbec520cc2 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 17:22:34 +0530
Subject: [PATCH 58/64] fix: ui fixes in theme colors, settings modal etc.
---
packages/imagekit-editor-dev/package.json | 2 +-
.../src/components/editor/ActionBar.tsx | 2 +
.../src/components/header/NavbarItem.tsx | 23 ++-
.../src/components/header/SettingsModal.tsx | 6 +
.../components/header/TemplateNameInput.tsx | 4 +-
.../src/components/header/TemplateStatus.tsx | 7 +-
.../components/header/TemplatesDropdown.tsx | 52 +++--
.../src/components/header/index.tsx | 187 ++++++++++--------
.../templates/TemplatesLibraryView.tsx | 25 ++-
packages/imagekit-editor-dev/src/theme.ts | 17 +-
packages/imagekit-editor/package.json | 2 +-
11 files changed, 197 insertions(+), 130 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index 0c89a7f..0bf7a68 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.2",
+ "version": "2.2.0-stage-30-04-2026.6",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx
index 139957b..27eb7cb 100644
--- a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx
+++ b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx
@@ -66,6 +66,7 @@ export const ActionBar: FC = ({
size="md"
fontWeight="normal"
leftIcon={ }
+ _hover={{ bg: "gray.100" }}
onClick={() => setShowOriginal(!showOriginal)}
>
{showOriginal ? "Show Transformed" : "Show Original"}
@@ -105,6 +106,7 @@ export const ActionBar: FC = ({
variant="ghost"
size="md"
fontWeight="normal"
+ _hover={{ bg: "gray.100" }}
onClick={() => window.open(currentImage, "_blank")}
>
Open image in new tab
diff --git a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
index 10aabcb..e6c997a 100644
--- a/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/NavbarItem.tsx
@@ -1,6 +1,7 @@
import { Button, type ButtonProps, Icon, IconButton } from "@chakra-ui/react"
import type React from "react"
import { forwardRef } from "react"
+import { chakraAny } from "../../utils"
interface NavbarItemProps extends Omit {
icon?: React.ReactElement
@@ -13,6 +14,8 @@ export const NavbarItem = forwardRef(
{ icon, label, variant = "button", children, ...props },
ref,
) {
+ const ButtonAny = chakraAny(Button)
+ const IconButtonAny = chakraAny(IconButton)
const commonStyles = {
variant: "ghost" as const,
borderRadius: "md" as const,
@@ -21,35 +24,39 @@ export const NavbarItem = forwardRef(
mx: "2" as const,
fontSize: "sm" as const,
fontWeight: "medium" as const,
+ color: "editorBattleshipGrey.700",
_hover: {
- bg: "editorBattleshipGrey.50",
+ bg: "gray.100",
},
}
// If only icon is provided (no children or label to display), use icon variant
if (variant === "icon" || (!children && icon && !label)) {
return (
- : undefined}
- color="editorBattleshipGrey.500"
+ icon={
+ icon ? (
+
+ ) : undefined
+ }
{...commonStyles}
- {...props}
+ {...(props as unknown as Record)}
/>
)
}
return (
- )}
>
{children || label}
-
+
)
},
)
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 962da82..36bf524 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -203,6 +203,11 @@ export function SettingsModal({
[canChangeVisibility],
)
+ // Get height of the current viewport
+ const viewportHeight = useMemo(() => {
+ return window.innerHeight
+ }, [])
+
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
@@ -301,6 +306,7 @@ export function SettingsModal({
{ value: "onlyMe", label: "Only to me" },
{ value: "everyone", label: "Shared with everyone" },
]}
+ menuPlacement={viewportHeight > 900 ? "auto" : "top"}
formatOptionLabel={(opt) => (
)
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index 4f4f359..b9137a0 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -28,6 +28,7 @@ const FlexAny = chakraAny(Flex)
const PopoverContentAny = chakraAny(PopoverContent)
const TooltipAny = chakraAny(Tooltip)
const PopoverBodyAny = chakraAny(PopoverBody)
+const ButtonAny = chakraAny(Button)
export function TemplateStatus() {
const syncStatus = useEditorStore((s) => s.syncStatus)
@@ -195,7 +196,7 @@ export function TemplateStatus() {
cursor={isInteractive ? "pointer" : "default"}
pointerEvents={isInteractive ? "auto" : "none"}
_hover={{
- bg: isInteractive ? "editorGray.200" : "transparent",
+ bg: isInteractive ? "gray.100" : "transparent",
}}
>
{isUnsavedState && (
- void save()}
@@ -234,7 +235,7 @@ export function TemplateStatus() {
isDisabled={templateStorageWriteBlocked}
>
Save
-
+
)}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 2f27492..5c648d0 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -15,6 +15,7 @@ import {
PopoverTrigger,
Spinner,
Text,
+ Tooltip,
useDisclosure,
} from "@chakra-ui/react"
import { PiCaretDown } from "@react-icons/all-files/pi/PiCaretDown"
@@ -53,6 +54,7 @@ const InputAny = chakraAny(Input)
const BadgeAny = chakraAny(Badge)
const AvatarAny = chakraAny(Avatar)
const SpinnerAny = chakraAny(Spinner)
+const TooltipAny = chakraAny(Tooltip)
const MAX_VISIBLE = 5
@@ -300,7 +302,7 @@ export function TemplatesDropdown({
paddingY="2"
height="10"
marginX="2"
- _hover={{ bg: "editorGray.200" }}
+ _hover={{ bg: "gray.100" }}
transition="background-color 0.15s"
aria-label="Open templates dropdown"
>
@@ -407,15 +409,21 @@ export function TemplatesDropdown({
{/* Name + badge */}
-
- {truncateTemplateName(templateName)}
-
+
+ {truncateTemplateName(templateName)}
+
+
Current
@@ -494,17 +502,23 @@ export function TemplatesDropdown({
/>
{/* Template name */}
-
- {truncateTemplateName(record.name)}
-
+
+ {truncateTemplateName(record.name)}
+
+
{/* Creator on hover + pin (always visible for pinned, hover for others) */}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 9447873..443e4b4 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -21,6 +21,7 @@ import {
type RequiredMetadata,
useEditorStore,
} from "../../store"
+import { chakraAny } from "../../utils"
import { NavbarItem } from "./NavbarItem"
import { SettingsModal } from "./SettingsModal"
import { TemplateNameInput } from "./TemplateNameInput"
@@ -64,7 +65,13 @@ export const Header = ({
onClose,
exportOptions,
onViewAllTemplates,
-}: HeaderProps) => {
+}: HeaderProps): React.ReactElement => {
+ const FlexAny = chakraAny(Flex)
+ const DividerAny = chakraAny(Divider)
+ const MenuButtonAny = chakraAny(MenuButton)
+ const MenuListAny = chakraAny(MenuList)
+ const MenuItemAny = chakraAny(MenuItem)
+
const { imageList, originalImageList, currentImage } = useEditorStore()
const templateId = useEditorStore((s) => s.templateId)
const templateIsPrivate = useEditorStore((s) => s.templateIsPrivate)
@@ -102,8 +109,15 @@ export const Header = ({
}
}, [provider, templateId, syncStatus])
+ const visibleExportOptions =
+ exportOptions?.filter((exportOption) =>
+ typeof exportOption.isVisible === "boolean"
+ ? exportOption.isVisible
+ : exportOption.isVisible(imageList, currentImage),
+ ) ?? []
+
return (
-
{provider ? (
<>
-
+
{templateId && (
)}
-
-
+ setIsSettingsOpen(true)}
/>
-
-
+
-
-
+
>
) : null}
-
+
-
+
- {exportOptions
- ?.filter((exportOption) =>
- typeof exportOption.isVisible === "boolean"
- ? exportOption.isVisible
- : exportOption.isVisible(imageList, currentImage),
- )
- .map((exportOption) => (
-
- {exportOption.type === "button" ? (
- (
+
+ {exportOption.type === "button" ? (
+ {
+ const images = imageList.map((image, index) => ({
+ url: image,
+ file: originalImageList[index],
+ }))
+ const cImage = images.find(
+ (image) => image.url === currentImage,
+ )
+ exportOption.onClick(images, {
+ // biome-ignore lint/style/noNonNullAssertion:
+ url: cImage!.url,
+ // biome-ignore lint/style/noNonNullAssertion:
+ file: cImage!.file,
+ })
+ }}
+ />
+ ) : (
+
+ {
- const images = imageList.map((image, index) => ({
- url: image,
- file: originalImageList[index],
- }))
- const cImage = images.find(
- (image) => image.url === currentImage,
- )
- exportOption.onClick(images, {
- // biome-ignore lint/style/noNonNullAssertion:
- url: cImage!.url,
- // biome-ignore lint/style/noNonNullAssertion:
- file: cImage!.file,
- })
- }}
- />
- ) : (
-
-
- {exportOption.label}
-
-
- {exportOption.options
- .filter((option) =>
- typeof option.isVisible === "boolean"
- ? option.isVisible
- : option.isVisible(imageList, currentImage),
- )
- .map((option) => (
-
- ))}
-
-
- )}
-
- ))}
-
+
+ {exportOption.options
+ .filter((option) =>
+ typeof option.isVisible === "boolean"
+ ? option.isVisible
+ : option.isVisible(imageList, currentImage),
+ )
+ .map((option) => (
+ {
+ const images = imageList.map((image, index) => ({
+ url: image,
+ file: originalImageList[index],
+ }))
+ const cImage = images.find(
+ (image) => image.url === currentImage,
+ )
+ option.onClick(images, {
+ // biome-ignore lint/style/noNonNullAssertion:
+ url: cImage!.url,
+ // biome-ignore lint/style/noNonNullAssertion:
+ file: cImage!.file,
+ })
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+ )}
+ {exportOptionIndex < visibleExportOptions.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
)}
-
+
)
}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 23e9190..5b74239 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -58,11 +58,11 @@ const BadgeAny = chakraAny(Badge)
const InputGroupAny = chakraAny(InputGroup)
const InputLeftElementAny = chakraAny(InputLeftElement)
const InputAny = chakraAny(Input)
+const TooltipAny = chakraAny(Tooltip)
const IconAny = chakraAny(Icon)
const PopoverContentAny = chakraAny(PopoverContent)
const PopoverBodyAny = chakraAny(PopoverBody)
const DividerAny = chakraAny(Divider)
-const TooltipAny = chakraAny(Tooltip)
function formatRelativeTime(ts: number): string {
const now = Date.now()
@@ -724,15 +724,22 @@ function TemplateRow({
{/* Name + transform count */}
-
- {truncateTemplateName(record.name)}
-
+
+ {truncateTemplateName(record.name)}
+
+
{isCurrent && (
Current
diff --git a/packages/imagekit-editor-dev/src/theme.ts b/packages/imagekit-editor-dev/src/theme.ts
index 3f6213a..23d9caa 100644
--- a/packages/imagekit-editor-dev/src/theme.ts
+++ b/packages/imagekit-editor-dev/src/theme.ts
@@ -19,7 +19,7 @@ export const themeOverrides = {
},
colors: {
editorBattleshipGrey: {
- "50": "#f9f6fd",
+ "50": "#f7fafc",
"100": "#e2dfe5",
"200": "#c7c5ca",
"300": "#a7a5aa",
@@ -42,6 +42,21 @@ export const themeOverrides = {
"800": "#022664",
"900": "#011332",
},
+ // Alias Chakra's default "blue" palette to our editor brand blue.
+ // This keeps MenuItem active/focus states, focus rings, and all `colorScheme="blue"`
+ // components visually consistent across the editor.
+ blue: {
+ "50": "#E6EFFF",
+ "100": "#B9D2FE",
+ "200": "#8CB5FD",
+ "300": "#5F98FC",
+ "400": "#327BFB",
+ "500": "#055EFA",
+ "600": "#044BC8",
+ "700": "#033896",
+ "800": "#022664",
+ "900": "#011332",
+ },
editorBlueyGrey: "#9da3ae",
editorLightBlueGrey: "#3D85C6",
editorBrick: "#d0312a",
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index e6e4f9b..0fc612a 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.2",
+ "version": "2.2.0-stage-30-04-2026.6",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From d25f0a975a1a6360613d311259ee3ffa7e1891b3 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 21:18:33 +0530
Subject: [PATCH 59/64] fix: permission handling working
---
packages/imagekit-editor-dev/package.json | 2 +-
.../src/ImageKitEditor.tsx | 38 ++-
.../src/components/header/SettingsModal.tsx | 72 ++---
.../components/header/TemplatesDropdown.tsx | 283 ++++++++++--------
.../templates/TemplatesLibraryView.tsx | 132 ++++----
.../context/TemplatePermissionsContext.tsx | 152 ++++++++++
packages/imagekit-editor-dev/src/index.tsx | 5 +
packages/imagekit-editor/package.json | 2 +-
8 files changed, 435 insertions(+), 251 deletions(-)
create mode 100644 packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index 0bf7a68..a211747 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.6",
+ "version": "2.2.0-stage-30-04-2026.11",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index e6cc586..b25b59f 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -9,6 +9,8 @@ import React, {
} from "react"
import { EditorLayout, EditorWrapper } from "./components/editor"
import type { HeaderProps } from "./components/header"
+import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext"
+import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
import {
applyTemplateStorageAccessFailure,
@@ -89,13 +91,25 @@ interface EditorProps {
* Omit or pass `null` to disable template sync UI.
*/
templateStorage?: TemplateStorageProvider | null
+ /**
+ * Host-controlled, per-template permissions for template management UI.
+ * If omitted, the editor defaults to allowing all actions.
+ */
+ getTemplatePermissions?: GetTemplatePermissions
}
function ImageKitEditorImpl(
props: EditorProps,
ref: React.Ref,
) {
- const { theme, initialImages, signer, focusObjects, templateStorage } = props
+ const {
+ theme,
+ initialImages,
+ signer,
+ focusObjects,
+ templateStorage,
+ getTemplatePermissions,
+ } = props
const {
addImage,
addImages,
@@ -217,15 +231,19 @@ function ImageKitEditorImpl(
return (
-
-
-
-
-
+
+
+
+
+
+
+
)
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 36bf524..aa9b3db 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -6,6 +6,7 @@ import { PiX } from "@react-icons/all-files/pi/PiX"
import type React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import Select, { type StylesConfig } from "react-select"
+import { useTemplatePermissions } from "../../context/TemplatePermissionsContext"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
import type { TemplateRecord } from "../../storage/types"
@@ -55,6 +56,7 @@ export function SettingsModal({
onDeleted,
}: SettingsModalProps) {
const provider = useTemplateStorage()
+ const permissions = useTemplatePermissions(data)
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
@@ -73,50 +75,11 @@ export function SettingsModal({
const [localVisibility, setLocalVisibility] = useState(() =>
visibilityFromRecord(data),
)
- // Whether the current user is allowed to change visibility (only the creator can).
- const [canChangeVisibility, setCanChangeVisibility] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
- // Fetch authoritative visibility + creator check from the API.
- useEffect(() => {
- let cancelled = false
-
- if (!provider) {
- setCanChangeVisibility(true)
- return
- }
-
- provider
- .getTemplate(data.id)
- .then((record) => {
- if (cancelled) return
- if (!record) {
- setCanChangeVisibility(true)
- return
- }
- setLocalVisibility(visibilityFromRecord(record))
- const session = provider.getCurrentUserSession() as {
- id?: string
- } | null
- setCanChangeVisibility(record.createdBy.userId === session?.id)
- })
- .catch((err) => {
- if (cancelled) return
- if (
- applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
- ) {
- onCloseRef.current()
- }
- })
-
- return () => {
- cancelled = true
- }
- }, [provider, data.id, denyTemplateStorageAccess])
-
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -194,13 +157,15 @@ export function SettingsModal({
fontSize: "12px",
minHeight: "32px",
borderColor: "#E2E8F0",
- backgroundColor: canChangeVisibility ? base.backgroundColor : "#F7FAFC",
- opacity: canChangeVisibility ? 1 : 0.6,
+ backgroundColor: permissions.changeVisibility
+ ? base.backgroundColor
+ : "#F7FAFC",
+ opacity: permissions.changeVisibility ? 1 : 0.6,
}),
menu: (base) => ({ ...base, zIndex: 10 }),
option: (base) => ({ ...base, fontSize: "12px" }),
}),
- [canChangeVisibility],
+ [permissions.changeVisibility],
)
// Get height of the current viewport
@@ -276,6 +241,7 @@ export function SettingsModal({
}
placeholder="Enter template name"
fontSize="sm"
+ isDisabled={!permissions.rename}
/>
@@ -299,7 +265,7 @@ export function SettingsModal({
: "Only to me",
}}
onChange={(option) => {
- if (!canChangeVisibility || !option) return
+ if (!permissions.changeVisibility || !option) return
setLocalVisibility(option.value as Visibility)
}}
options={[
@@ -319,7 +285,7 @@ export function SettingsModal({
)}
styles={selectStyles}
isSearchable={false}
- isDisabled={!canChangeVisibility}
+ isDisabled={!permissions.changeVisibility}
/>
@@ -464,12 +430,19 @@ export function SettingsModal({
}
fontSize="sm"
fontWeight="medium"
- onClick={
- isDeleting || isSaving || showDeleteConfirm
+ onClick={() => {
+ if (!permissions.delete) return
+
+ return isDeleting || isSaving || showDeleteConfirm
? undefined
: () => setShowDeleteConfirm(true)
- }
+ }}
aria-disabled={isDeleting || isSaving || showDeleteConfirm}
+ disabled={!permissions.delete}
+ _disabled={{
+ color: "gray.400",
+ cursor: "not-allowed",
+ }}
>
{isDeleting ? "Deleting…" : "Delete"}
@@ -538,6 +511,11 @@ export function SettingsModal({
isSaving ||
templateStorageWriteBlocked
}
+ disabled={!permissions.save}
+ _disabled={{
+ bg: "blue.200",
+ cursor: "not-allowed",
+ }}
>
{isSaving ? "Saving…" : "Save"}
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 5c648d0..4149e75 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -28,6 +28,7 @@ import { PiPushPinFill } from "@react-icons/all-files/pi/PiPushPinFill"
import { PiSquaresFourLight } from "@react-icons/all-files/pi/PiSquaresFourLight"
import type React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useTemplatePermissions } from "../../context/TemplatePermissionsContext"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useTemplateSync } from "../../hooks/useTemplateSync"
import type { TemplateRecord } from "../../storage"
@@ -58,6 +59,142 @@ const TooltipAny = chakraAny(Tooltip)
const MAX_VISIBLE = 5
+// ---------------------------------------------------------------------------
+// DropdownTemplateRow — extracted so hooks (useTemplatePermissions) can be used
+// ---------------------------------------------------------------------------
+
+interface DropdownTemplateRowProps {
+ record: TemplateRecord
+ isHovered: boolean
+ pinningId: string | null
+ onSelect(record: TemplateRecord): void
+ onTogglePin(record: TemplateRecord): void
+ onMouseEnter(): void
+ onMouseLeave(): void
+}
+
+function DropdownTemplateRow({
+ record,
+ isHovered,
+ pinningId,
+ onSelect,
+ onTogglePin,
+ onMouseEnter,
+ onMouseLeave,
+}: DropdownTemplateRowProps) {
+ const permissions = useTemplatePermissions(record)
+ const creatorLabel = record.createdBy.name || record.createdBy.email
+ const recordNameUI = formatTemplateNameForUI(record.name)
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: Not necessary for this component
+ onSelect(record)}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ transition="background-color 0.15s"
+ >
+ {/* Visibility Icon */}
+
+
+ {/* Template name */}
+
+
+ {truncateTemplateName(record.name)}
+
+
+
+ {/* Creator on hover + pin (always visible for pinned, hover for others) */}
+
+ {/* Creator: always rendered, shown/hidden via opacity to avoid layout shift */}
+
+
+
+ {creatorLabel}
+
+
+
+ {/* Pin: only rendered when user has permission */}
+ {permissions.pin && (
+ ) => {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+ {pinningId === record.id ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ )
+}
+
interface TemplatesDropdownProps {
onViewAllTemplates?: () => void
}
@@ -463,136 +600,22 @@ export function TemplatesDropdown({
) : (
- filtered.map((record) =>
- (() => {
- const isHovered = hoveredTemplateId === record.id
- const creatorLabel =
- record.createdBy.name || record.createdBy.email
- const recordNameUI = formatTemplateNameForUI(record.name)
-
- return (
- // biome-ignore lint/a11y/useSemanticElements: Not necessary for this component
- handleSelect(record)}
- onMouseEnter={() => setHoveredTemplateId(record.id)}
- onMouseLeave={() =>
- setHoveredTemplateId((current) =>
- current === record.id ? null : current,
- )
- }
- transition="background-color 0.15s"
- >
- {/* Visibility Icon */}
-
-
- {/* Template name */}
-
-
- {truncateTemplateName(record.name)}
-
-
-
- {/* Creator on hover + pin (always visible for pinned, hover for others) */}
-
- {/* Creator: always rendered, shown/hidden via opacity to avoid layout shift */}
-
-
-
- {creatorLabel}
-
-
-
- {/* Pin: always rendered, shown/hidden via opacity to avoid layout shift */}
- ) => {
- e.stopPropagation()
- handleTogglePin(record)
- }}
- >
- {pinningId === record.id ? (
-
- ) : (
-
- )}
-
-
-
- )
- })(),
- )
+ filtered.map((record) => (
+ setHoveredTemplateId(record.id)}
+ onMouseLeave={() =>
+ setHoveredTemplateId((current) =>
+ current === record.id ? null : current,
+ )
+ }
+ />
+ ))
)}
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index 5b74239..f1b3e32 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -31,6 +31,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"
import humanDate from "human-date"
import type React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useTemplatePermissions } from "../../context/TemplatePermissionsContext"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useDebounce } from "../../hooks/useDebounce"
import type { TemplateRecord } from "../../storage"
@@ -681,6 +682,7 @@ function TemplateRow({
isActive = false,
}: TemplateRowProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const permissions = useTemplatePermissions(record)
const recordNameUI = formatTemplateNameForUI(record.name)
return (
{/* Pin */}
- ) => {
- e.stopPropagation()
- onTogglePin(record)
- }}
- >
- {isPinning ? (
-
- ) : (
-
- )}
-
+ {permissions.pin && (
+ ) => {
+ e.stopPropagation()
+ onTogglePin(record)
+ }}
+ >
+ {isPinning ? (
+
+ ) : (
+
+ )}
+
+ )}
{/* Name + transform count */}
@@ -851,48 +857,50 @@ function TemplateRow({
- ) => e.stopPropagation()}
- >
-
-
- Are you sure you want to delete this template? This action is
- irreversible.
-
-
- setShowDeleteConfirm(false)}
- color="editorBattleshipGrey.500"
- _hover={{
- color: "editorBattleshipGrey.800",
- bg: "editorGray.50",
- }}
- >
- Cancel
-
- }
- onClick={() => {
- setShowDeleteConfirm(false)
- onDelete(record)
- }}
- >
- Delete
-
+ {permissions.delete && (
+ ) => e.stopPropagation()}
+ >
+
+
+ Are you sure you want to delete this template? This action is
+ irreversible.
+
+
+ setShowDeleteConfirm(false)}
+ color="editorBattleshipGrey.500"
+ _hover={{
+ color: "editorBattleshipGrey.800",
+ bg: "editorGray.50",
+ }}
+ >
+ Cancel
+
+ }
+ onClick={() => {
+ setShowDeleteConfirm(false)
+ onDelete(record)
+ }}
+ >
+ Delete
+
+
-
-
+
+ )}
)
diff --git a/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx
new file mode 100644
index 0000000..7f6d6bb
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.tsx
@@ -0,0 +1,152 @@
+import type React from "react"
+import { createContext, useContext, useMemo } from "react"
+import type { TemplateRecord } from "../storage"
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/**
+ * Raw permission buckets returned by the host-supplied `getTemplatePermissions`
+ * callback. Each bucket maps to one or more editor actions.
+ *
+ * - `create` – can create new templates
+ * - `view` – template is visible in lists / can be loaded
+ * - `manage` – can rename, save transforms, open settings
+ * - `changeVisibility` – can toggle isPrivate (creator-only in most hosts)
+ * - `delete` – can delete the template
+ * - `pin` – can pin / unpin the template
+ * - `reason` – optional human-readable denial message per bucket
+ */
+export type TemplatePermissionBuckets = {
+ create: boolean
+ view: boolean
+ manage: boolean
+ changeVisibility: boolean
+ delete: boolean
+ pin: boolean
+ reason?: Partial<
+ Record<
+ "create" | "view" | "manage" | "changeVisibility" | "delete" | "pin",
+ string | undefined
+ >
+ >
+}
+
+/**
+ * Granular action-level permissions derived from `TemplatePermissionBuckets`.
+ * Components consume this for conditional rendering.
+ */
+export type TemplatePermissions = {
+ create: boolean
+ rename: boolean
+ changeVisibility: boolean
+ save: boolean
+ delete: boolean
+ pin: boolean
+}
+
+/**
+ * Host-supplied callback: given a template record, return its permission buckets.
+ * If omitted, all actions are allowed (open / anonymous usage).
+ */
+export type GetTemplatePermissions = (
+ template: TemplateRecord,
+) => TemplatePermissionBuckets
+
+// ---------------------------------------------------------------------------
+// Defaults
+// ---------------------------------------------------------------------------
+
+const ALLOW_ALL: TemplatePermissionBuckets = {
+ create: true,
+ view: true,
+ manage: true,
+ changeVisibility: true,
+ delete: true,
+ pin: true,
+}
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+const TemplatePermissionsContext = createContext(
+ null,
+)
+
+export function TemplatePermissionsContextProvider({
+ getTemplatePermissions,
+ children,
+}: {
+ getTemplatePermissions?: GetTemplatePermissions
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useGetTemplatePermissions(): GetTemplatePermissions | null {
+ return useContext(TemplatePermissionsContext)
+}
+
+// ---------------------------------------------------------------------------
+// Resolution helpers
+// ---------------------------------------------------------------------------
+
+export function resolveTemplatePermissionBuckets(args: {
+ template: TemplateRecord | null
+ getTemplatePermissions: GetTemplatePermissions | null
+}): TemplatePermissionBuckets {
+ const { template, getTemplatePermissions } = args
+
+ if (!template || !getTemplatePermissions) return ALLOW_ALL
+
+ return getTemplatePermissions(template)
+}
+
+export function resolveTemplatePermissions(args: {
+ template: TemplateRecord | null
+ getTemplatePermissions: GetTemplatePermissions | null
+}): TemplatePermissions {
+ const b = resolveTemplatePermissionBuckets(args)
+
+ return {
+ create: b.create,
+ rename: b.manage,
+ changeVisibility: b.changeVisibility,
+ save: b.manage,
+ delete: b.delete,
+ pin: b.pin,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Hooks
+// ---------------------------------------------------------------------------
+
+export function useTemplatePermissions(
+ template: TemplateRecord | null,
+): TemplatePermissions {
+ const getTemplatePermissions = useGetTemplatePermissions()
+
+ return useMemo(
+ () => resolveTemplatePermissions({ template, getTemplatePermissions }),
+ [template, getTemplatePermissions],
+ )
+}
+
+export function useTemplatePermissionBuckets(
+ template: TemplateRecord | null,
+): TemplatePermissionBuckets {
+ const getTemplatePermissions = useGetTemplatePermissions()
+
+ return useMemo(
+ () =>
+ resolveTemplatePermissionBuckets({ template, getTemplatePermissions }),
+ [template, getTemplatePermissions],
+ )
+}
diff --git a/packages/imagekit-editor-dev/src/index.tsx b/packages/imagekit-editor-dev/src/index.tsx
index be0d7fe..d1f45a2 100644
--- a/packages/imagekit-editor-dev/src/index.tsx
+++ b/packages/imagekit-editor-dev/src/index.tsx
@@ -1,3 +1,8 @@
+export type {
+ GetTemplatePermissions,
+ TemplatePermissionBuckets,
+ TemplatePermissions,
+} from "./context/TemplatePermissionsContext"
export type { ImageKitEditorProps, ImageKitEditorRef } from "./ImageKitEditor"
export { ImageKitEditor } from "./ImageKitEditor"
export { DEFAULT_FOCUS_OBJECTS } from "./schema"
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 0fc612a..8e3921e 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.6",
+ "version": "2.2.0-stage-30-04-2026.11",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 2d472329a48516136ee361756d4b9728b51b9ad5 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 21:40:28 +0530
Subject: [PATCH 60/64] fix: combined delete into a single flow
---
packages/imagekit-editor-dev/package.json | 2 +-
.../components/header/SettingsModal.test.tsx | 57 ++++++++-----------
.../src/components/header/SettingsModal.tsx | 27 +++++----
.../src/components/header/index.tsx | 4 +-
.../templates/TemplatesLibraryView.tsx | 10 ++--
packages/imagekit-editor/package.json | 2 +-
6 files changed, 49 insertions(+), 53 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index a211747..e3e4642 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.11",
+ "version": "2.2.0-stage-30-04-2026.12",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
index 4ea60d2..7c832db 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.test.tsx
@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext"
import type { TemplateRecord } from "../../storage"
+import type { TemplateStorageProvider } from "../../storage/types"
import { useEditorStore } from "../../store"
import { SettingsModal } from "./SettingsModal"
@@ -31,36 +32,30 @@ function renderModal(opts: {
data?: TemplateRecord
onClose?: () => void
onSaved?: (r: TemplateRecord) => void
- onDeleted?: () => void
- deleteTemplate?: ((id: string) => Promise) | undefined
- // biome-ignore lint/suspicious/noExplicitAny: test stub
- saveTemplate?: (r: any) => Promise
+ onDeleteRequested?: ((id: string) => Promise) | undefined
+ saveTemplate?: (r: unknown) => Promise
}) {
const data = opts.data ?? makeTemplate()
- const provider = {
+ const provider: TemplateStorageProvider = {
getProviderName: () => "test",
getCurrentUserSession: () => ({ id: "u1" }),
listTemplates: async () => [],
getTemplate: async () => data,
- // biome-ignore lint/suspicious/noExplicitAny: test stub
saveTemplate:
- opts.saveTemplate ?? (async (_r: any) => makeTemplate({ id: "saved" })),
+ opts.saveTemplate ??
+ (async (_r: unknown) => makeTemplate({ id: "saved" })),
setTemplatePinned: vi.fn(),
- ...(opts.deleteTemplate !== undefined
- ? { deleteTemplate: opts.deleteTemplate }
- : {}),
}
return render(
- {/* biome-ignore lint/suspicious/noExplicitAny: test stub */}
-
+
,
@@ -77,9 +72,9 @@ describe("SettingsModal", () => {
})
describe("delete confirmation flow", () => {
- it("does NOT call deleteTemplate immediately when Delete is clicked", async () => {
- const deleteTemplate = vi.fn(async () => {})
- renderModal({ deleteTemplate })
+ it("does NOT call onDeleteRequested immediately when Delete is clicked", async () => {
+ const onDeleteRequested = vi.fn(async () => {})
+ renderModal({ onDeleteRequested })
expect(await screen.findByText("Template Settings")).toBeTruthy()
@@ -87,12 +82,12 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Delete"))
})
- // deleteTemplate must NOT have been called yet
- expect(deleteTemplate).not.toHaveBeenCalled()
+ // onDeleteRequested must NOT have been called yet
+ expect(onDeleteRequested).not.toHaveBeenCalled()
})
it("shows the confirmation panel after clicking Delete", async () => {
- renderModal({ deleteTemplate: vi.fn(async () => {}) })
+ renderModal({ onDeleteRequested: vi.fn(async () => {}) })
expect(await screen.findByText("Template Settings")).toBeTruthy()
@@ -106,9 +101,9 @@ describe("SettingsModal", () => {
expect(screen.getByText("Yes, delete")).toBeTruthy()
})
- it("does NOT call deleteTemplate when confirmation is dismissed with Cancel", async () => {
- const deleteTemplate = vi.fn(async () => {})
- renderModal({ deleteTemplate })
+ it("does NOT call onDeleteRequested when confirmation is dismissed with Cancel", async () => {
+ const onDeleteRequested = vi.fn(async () => {})
+ renderModal({ onDeleteRequested })
expect(await screen.findByText("Template Settings")).toBeTruthy()
@@ -126,16 +121,15 @@ describe("SettingsModal", () => {
// Panel should be gone
expect(screen.queryByText("Delete template?")).toBeNull()
- // deleteTemplate still must not have been called
- expect(deleteTemplate).not.toHaveBeenCalled()
+ // onDeleteRequested still must not have been called
+ expect(onDeleteRequested).not.toHaveBeenCalled()
})
- it("calls deleteTemplate only after clicking Yes, delete", async () => {
- const deleteTemplate = vi.fn(async () => {})
- const onDeleted = vi.fn()
+ it("calls onDeleteRequested only after clicking Yes, delete", async () => {
+ const onDeleteRequested = vi.fn(async () => {})
const onClose = vi.fn()
- renderModal({ deleteTemplate, onDeleted, onClose })
+ renderModal({ onDeleteRequested, onClose })
expect(await screen.findByText("Template Settings")).toBeTruthy()
@@ -152,16 +146,15 @@ describe("SettingsModal", () => {
})
await waitFor(() => {
- expect(deleteTemplate).toHaveBeenCalledTimes(1)
- expect(deleteTemplate).toHaveBeenCalledWith("t-1")
+ expect(onDeleteRequested).toHaveBeenCalledTimes(1)
+ expect(onDeleteRequested).toHaveBeenCalledWith("t-1")
})
- expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalled()
})
it("Delete button is disabled while confirmation panel is open", async () => {
- renderModal({ deleteTemplate: vi.fn(async () => {}) })
+ renderModal({ onDeleteRequested: vi.fn(async () => {}) })
expect(await screen.findByText("Template Settings")).toBeTruthy()
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index aa9b3db..48925ea 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -31,8 +31,14 @@ export interface SettingsModalProps {
onClose(): void
/** Called with the updated record after a successful save. */
onSaved?(updated: TemplateRecord): void
- /** Called after the template is successfully deleted. */
- onDeleted?(): void
+ /**
+ * Called after the user confirms deletion.
+ *
+ * `SettingsModal` does not delete templates itself. Callers must provide a
+ * single delete implementation that can also handle required side-effects
+ * (list refresh, editor reset, etc.).
+ */
+ onDeleteRequested?(id: string): Promise | void
}
// ---------------------------------------------------------------------------
@@ -53,7 +59,7 @@ export function SettingsModal({
data,
onClose,
onSaved,
- onDeleted,
+ onDeleteRequested,
}: SettingsModalProps) {
const provider = useTemplateStorage()
const permissions = useTemplatePermissions(data)
@@ -124,13 +130,12 @@ export function SettingsModal({
// Delete
// -------------------------------------------------------------------------
const handleDeleteConfirmed = async () => {
- if (!provider || !provider.deleteTemplate) return
+ if (!onDeleteRequested) return
setIsDeleting(true)
setShowDeleteConfirm(false)
try {
- await provider.deleteTemplate(data.id)
- onDeleted?.()
+ await onDeleteRequested(data.id)
onClose()
} catch (err) {
if (
@@ -410,8 +415,8 @@ export function SettingsModal({
borderTopWidth="1px"
borderColor="editorGray.300"
>
- {/* Delete button — only shown when deleteTemplate is supported */}
- {provider?.deleteTemplate ? (
+ {/* Delete button — only shown when deletion is supported */}
+ {onDeleteRequested ? (
{
if (!permissions.delete) return
-
- return isDeleting || isSaving || showDeleteConfirm
- ? undefined
- : () => setShowDeleteConfirm(true)
+ if (isDeleting || isSaving || showDeleteConfirm) return
+ setShowDeleteConfirm(true)
}}
aria-disabled={isDeleting || isSaving || showDeleteConfirm}
disabled={!permissions.delete}
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 443e4b4..46d7456 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -280,7 +280,9 @@ export const Header = ({
templateIsPrivate: updated.isPrivate,
})
}}
- onDeleted={() => {
+ onDeleteRequested={async (id) => {
+ if (!provider?.deleteTemplate) return
+ await provider.deleteTemplate(id)
useEditorStore.getState().resetToNewTemplate()
}}
/>
diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
index f1b3e32..5256aa3 100644
--- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
+++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx
@@ -213,7 +213,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
}
}
- const handleDeleteTemplate = useCallback(
+ const deleteTemplateAndCleanup = useCallback(
async (record: TemplateRecord) => {
if (!provider) return
if (!provider.deleteTemplate) return
@@ -621,7 +621,7 @@ export function TemplatesLibraryView({ onClose }: Props) {
}
onTogglePin={handleTogglePin}
isPinning={pinningId === record.id}
- onDelete={handleDeleteTemplate}
+ onDelete={deleteTemplateAndCleanup}
onSettings={handleOpenSettings}
isCurrent={isCurrent}
isActive={isActive}
@@ -649,10 +649,8 @@ export function TemplatesLibraryView({ onClose }: Props) {
prev.map((t) => (t.id === updated.id ? updated : t)),
)
}}
- onDeleted={() => {
- setTemplates((prev) =>
- prev.filter((t) => t.id !== settingsRecord.id),
- )
+ onDeleteRequested={async () => {
+ await deleteTemplateAndCleanup(settingsRecord)
}}
/>
)}
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 8e3921e..d580a14 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.11",
+ "version": "2.2.0-stage-30-04-2026.12",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 480ae65cb4ac00ca1d7664aebe467cdf7ab480de Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Thu, 30 Apr 2026 22:52:15 +0530
Subject: [PATCH 61/64] fix: permission errors in sync reported properly in
template status
---
packages/imagekit-editor-dev/package.json | 2 +-
.../components/header/TemplateStatus.test.tsx | 48 +++++++++++++++++++
.../src/components/header/TemplateStatus.tsx | 17 ++++---
packages/imagekit-editor/package.json | 2 +-
4 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index e3e4642..8e82e22 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.12",
+ "version": "2.2.0-stage-30-04-2026.15",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
index 2c068bc..d77c300 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
@@ -592,4 +592,52 @@ describe("TemplateStatus", () => {
expectStaysUnsavedAfterDelay(3500)
expectStaysUnsavedAfterDelay(10_000)
})
+
+ it("shows a permission-specific error label when writes are blocked (401/403)", async () => {
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ } as unknown as Parameters[0])
+
+ renderWithProvider()
+
+ act(() => {
+ useEditorStore.setState({
+ templateStorageWriteBlocked: true,
+ storageError: "You no longer have access to this template.",
+ } as unknown as Parameters[0])
+ useEditorStore
+ .getState()
+ .setSyncStatus("error", "You no longer have access to this template.")
+ })
+
+ expect(screen.getByText("Save failed")).toBeTruthy()
+ expect(screen.getByLabelText("template-status-error")).toBeTruthy()
+
+ // After the transient notification text disappears, the icon becomes interactive
+ // and hovering it should show the detailed popover contents.
+ act(() => {
+ vi.advanceTimersByTime(3500)
+ })
+ expect(screen.queryByText("Save failed")).toBeNull()
+
+ const errorIcon = screen.getByLabelText("template-status-error")
+ const popoverTrigger = errorIcon.closest(
+ '[aria-haspopup="dialog"]',
+ ) as HTMLElement | null
+ expect(popoverTrigger).toBeTruthy()
+
+ act(() => {
+ fireEvent.click(popoverTrigger as HTMLElement)
+ })
+
+ expect(await screen.findByText("Access required")).toBeTruthy()
+ expect(
+ screen.getByText(
+ "You don't have permission to save changes to this template.",
+ ),
+ ).toBeTruthy()
+ })
})
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index b9137a0..c887b61 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -32,7 +32,6 @@ const ButtonAny = chakraAny(Button)
export function TemplateStatus() {
const syncStatus = useEditorStore((s) => s.syncStatus)
- const storageError = useEditorStore((s) => s.storageError)
const isPristine = useEditorStore((s) => s.isPristine)
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
@@ -119,6 +118,8 @@ export function TemplateStatus() {
| "template-status-error"
const isUnsavedState = hasPendingLocalWork || syncStatus === "unsaved"
+ const isPermissionDeniedError =
+ syncStatus === "error" && templateStorageWriteBlocked
if (notificationVisible) {
// Unsynced edits take precedence over the last successful save result.
@@ -135,7 +136,7 @@ export function TemplateStatus() {
iconAriaLabel = "template-status-saved"
} else if (syncStatus === "error") {
activeIcon = MdSyncProblem
- activeColor = "yellow.600"
+ activeColor = isPermissionDeniedError ? "red.600" : "yellow.600"
notifText = "Save failed"
iconAriaLabel = "template-status-error"
} else {
@@ -166,13 +167,17 @@ export function TemplateStatus() {
? "Unsaved changes"
: lastSyncResult === "success"
? "All changes saved"
- : "Sync failed"
+ : isPermissionDeniedError
+ ? "Access required"
+ : "Save failed"
const popupBody = isUnsavedState
- ? "Your current local changes haven't been synced to the library yet."
+ ? "Your current local changes haven't been saved to the library yet."
: lastSyncResult === "success"
- ? `Your changes are synced to ${providerName} successfully.`
- : (storageError ?? "Failed to save changes. Please try again.")
+ ? `Your changes are saved to ${providerName} successfully.`
+ : isPermissionDeniedError
+ ? "You don't have permission to save changes to this template."
+ : "Failed to save changes. If this problem persists, please contact support."
return (
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index d580a14..6ca0a98 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.12",
+ "version": "2.2.0-stage-30-04-2026.15",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From be81a544cd2aa892ad00b087df3d3a7f79505919 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Fri, 1 May 2026 02:19:32 +0530
Subject: [PATCH 62/64] fix: restricting registration of onClick handler for
settings modal button when there is no templateId, fix flashing success
template sync state before failure, hover css for settings modal action
buttons
---
packages/imagekit-editor-dev/package.json | 2 +-
.../src/ImageKitEditor.tsx | 17 ++++----
.../src/components/header/SettingsModal.tsx | 40 ++++++++++++++-----
.../components/header/TemplateStatus.test.tsx | 25 ++++++++++++
.../src/components/header/TemplateStatus.tsx | 26 ++++++++----
.../components/header/TemplatesDropdown.tsx | 33 ++++++++-------
.../src/components/header/index.tsx | 16 ++++++--
.../src/hooks/useTemplateSync.ts | 16 ++++----
.../src/storage/templateAccessError.ts | 4 +-
packages/imagekit-editor-dev/src/store.ts | 22 +++++++++-
packages/imagekit-editor/package.json | 2 +-
11 files changed, 147 insertions(+), 56 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index 8e82e22..a06008b 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.15",
+ "version": "2.2.0-stage-30-04-2026.18",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
index b25b59f..1572c67 100644
--- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
+++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx
@@ -13,7 +13,7 @@ import type { GetTemplatePermissions } from "./context/TemplatePermissionsContex
import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
import {
- applyTemplateStorageAccessFailure,
+ isTemplateAccessDeniedError,
type TemplateStorageProvider,
} from "./storage"
import {
@@ -159,13 +159,14 @@ function ImageKitEditorImpl(
}
after.setLastSavedAt(Date.now())
} catch (err) {
- const { denyTemplateStorageAccess } = useEditorStore.getState()
- // Reuse existing access-denied mapping.
- if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
+ if (isTemplateAccessDeniedError(err)) {
+ useEditorStore
+ .getState()
+ .blockTemplateStorageWrites(
+ err instanceof Error
+ ? err.message
+ : "You no longer have access to this template.",
+ )
return
}
state.setSyncStatus(
diff --git a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
index 48925ea..15fe419 100644
--- a/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/SettingsModal.tsx
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useRef, useState } from "react"
import Select, { type StylesConfig } from "react-select"
import { useTemplatePermissions } from "../../context/TemplatePermissionsContext"
import { useTemplateStorage } from "../../context/TemplateStorageContext"
-import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
+import { isTemplateAccessDeniedError } from "../../storage/templateAccessError"
import type { TemplateRecord } from "../../storage/types"
import { useEditorStore } from "../../store"
import { chakraAny, formatTemplateNameForUI } from "../../utils"
@@ -66,9 +66,6 @@ export function SettingsModal({
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
- const denyTemplateStorageAccess = useEditorStore(
- (s) => s.denyTemplateStorageAccess,
- )
const onCloseRef = useRef(onClose)
useEffect(() => {
@@ -116,10 +113,16 @@ export function SettingsModal({
onSaved?.(updated)
onClose()
} catch (err) {
- if (
- applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
- ) {
+ if (isTemplateAccessDeniedError(err)) {
+ useEditorStore
+ .getState()
+ .blockTemplateStorageWrites(
+ err instanceof Error
+ ? err.message
+ : "You no longer have access to this template.",
+ )
onClose()
+ return
}
} finally {
setIsSaving(false)
@@ -138,9 +141,14 @@ export function SettingsModal({
await onDeleteRequested(data.id)
onClose()
} catch (err) {
- if (
- applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
- ) {
+ if (isTemplateAccessDeniedError(err)) {
+ useEditorStore
+ .getState()
+ .blockTemplateStorageWrites(
+ err instanceof Error
+ ? err.message
+ : "You no longer have access to this template.",
+ )
onClose()
return
}
@@ -422,12 +430,18 @@ export function SettingsModal({
as="button"
display="inline-flex"
alignItems="center"
+ px="4"
+ py="2"
+ borderRadius="md"
gap="2"
color={
isDeleting || isSaving || showDeleteConfirm
? "gray.400"
: "red.500"
}
+ _hover={{
+ bg: "red.50",
+ }}
cursor={
isDeleting || isSaving || showDeleteConfirm
? "not-allowed"
@@ -471,6 +485,9 @@ export function SettingsModal({
cursor={isDeleting || isSaving ? "not-allowed" : "pointer"}
onClick={isDeleting || isSaving ? undefined : onClose}
aria-disabled={isDeleting || isSaving}
+ _hover={{
+ bg: "gray.50",
+ }}
>
Cancel
@@ -489,6 +506,9 @@ export function SettingsModal({
? "blue.200"
: "blue.500"
}
+ _hover={{
+ bg: "blue.600",
+ }}
color="white"
fontSize="sm"
fontWeight="medium"
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
index d77c300..469475e 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx
@@ -67,6 +67,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
...(partial ?? {}),
} as unknown as Parameters[0])
}
@@ -93,6 +94,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 2,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
} as unknown as Parameters[0])
renderWithProvider()
@@ -107,6 +109,7 @@ describe("TemplateStatus", () => {
localChangeVersion: 1,
lastSyncedVersion: 1,
transformationConfigFormDirty: true,
+ lastSavedAt: Date.now(),
} as unknown as Parameters[0])
renderWithProvider()
@@ -120,6 +123,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 2,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
} as unknown as Parameters[0])
renderWithProvider()
@@ -149,6 +153,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
} as unknown as Parameters[0])
renderWithProvider({ saveTemplate })
@@ -197,6 +202,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
transformations: [
{
id: "t1",
@@ -273,6 +279,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
transformations: [
{
id: "t2",
@@ -353,6 +360,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
transformations: [
{
id: "t3",
@@ -599,6 +607,7 @@ describe("TemplateStatus", () => {
syncStatus: "saved",
localChangeVersion: 1,
lastSyncedVersion: 1,
+ lastSavedAt: Date.now(),
} as unknown as Parameters[0])
renderWithProvider()
@@ -640,4 +649,20 @@ describe("TemplateStatus", () => {
),
).toBeTruthy()
})
+
+ it("does not flash a saved success state on initial mount when no save has happened yet", () => {
+ // Initial template load paths set syncStatus="saved" even though no save completed in this session.
+ // In that case, we should not show the transient green success notification.
+ useEditorStore.setState({
+ isPristine: false,
+ syncStatus: "saved",
+ localChangeVersion: 1,
+ lastSyncedVersion: 1,
+ lastSavedAt: null,
+ } as unknown as Parameters[0])
+
+ renderWithProvider()
+ expect(screen.queryByText(/Saved to library/i)).toBeNull()
+ expect(screen.queryByLabelText("template-status-saved")).toBeNull()
+ })
})
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
index c887b61..f17b117 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx
@@ -33,6 +33,7 @@ const ButtonAny = chakraAny(Button)
export function TemplateStatus() {
const syncStatus = useEditorStore((s) => s.syncStatus)
const isPristine = useEditorStore((s) => s.isPristine)
+ const lastSavedAt = useEditorStore((s) => s.lastSavedAt)
const templateStorageWriteBlocked = useEditorStore(
(s) => s.templateStorageWriteBlocked,
)
@@ -49,6 +50,7 @@ export function TemplateStatus() {
"success" | "error" | null
>(null)
const timerRef = useRef | null>(null)
+ const prevSyncStatusRef = useRef(syncStatus)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
@@ -56,12 +58,18 @@ export function TemplateStatus() {
if (syncStatus === "saving") {
setNotificationVisible(true)
} else if (syncStatus === "saved") {
- setLastSyncResult("success")
- setNotificationVisible(true)
- timerRef.current = setTimeout(
- () => setNotificationVisible(false),
- NOTIFICATION_DURATION_MS,
- )
+ // Avoid flashing a "Saved" success state on initial template load.
+ // Only treat "saved" as a success event when it follows an actual save.
+ const prev = prevSyncStatusRef.current
+ const isSaveCompletion = prev === "saving" || lastSavedAt !== null
+ if (isSaveCompletion) {
+ setLastSyncResult("success")
+ setNotificationVisible(true)
+ timerRef.current = setTimeout(
+ () => setNotificationVisible(false),
+ NOTIFICATION_DURATION_MS,
+ )
+ }
} else if (syncStatus === "unsaved" || hasPendingLocalWork) {
setNotificationVisible(true)
timerRef.current = setTimeout(
@@ -80,7 +88,11 @@ export function TemplateStatus() {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
- }, [syncStatus, hasPendingLocalWork])
+ }, [syncStatus, hasPendingLocalWork, lastSavedAt])
+
+ useEffect(() => {
+ prevSyncStatusRef.current = syncStatus
+ }, [syncStatus])
if (
!provider ||
diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
index 4149e75..0468636 100644
--- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx
@@ -32,7 +32,7 @@ import { useTemplatePermissions } from "../../context/TemplatePermissionsContext
import { useTemplateStorage } from "../../context/TemplateStorageContext"
import { useTemplateSync } from "../../hooks/useTemplateSync"
import type { TemplateRecord } from "../../storage"
-import { applyTemplateStorageAccessFailure } from "../../storage/templateAccessError"
+import { isTemplateAccessDeniedError } from "../../storage/templateAccessError"
import { useEditorStore } from "../../store"
import {
chakraAny,
@@ -372,13 +372,14 @@ export function TemplatesDropdown({
prev.map((t) => (t.id === updated.id ? updated : t)),
)
} catch (err) {
- const { denyTemplateStorageAccess } = useEditorStore.getState()
- if (
- record.id === templateId &&
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
+ if (record.id === templateId && isTemplateAccessDeniedError(err)) {
+ useEditorStore
+ .getState()
+ .blockTemplateStorageWrites(
+ err instanceof Error
+ ? err.message
+ : "You no longer have access to this template.",
+ )
return
}
} finally {
@@ -396,13 +397,15 @@ export function TemplatesDropdown({
if (!saved) return
doLoadTemplate(pendingTemplate)
} catch (err) {
- const { denyTemplateStorageAccess, setSyncStatus } =
- useEditorStore.getState()
- if (
- applyTemplateStorageAccessFailure(err, {
- denyTemplateStorageAccess,
- })
- ) {
+ const { setSyncStatus } = useEditorStore.getState()
+ if (isTemplateAccessDeniedError(err)) {
+ useEditorStore
+ .getState()
+ .blockTemplateStorageWrites(
+ err instanceof Error
+ ? err.message
+ : "You no longer have access to this template.",
+ )
return
}
setSyncStatus(
diff --git a/packages/imagekit-editor-dev/src/components/header/index.tsx b/packages/imagekit-editor-dev/src/components/header/index.tsx
index 46d7456..a6b2521 100644
--- a/packages/imagekit-editor-dev/src/components/header/index.tsx
+++ b/packages/imagekit-editor-dev/src/components/header/index.tsx
@@ -99,8 +99,10 @@ export const Header = ({
})
.catch((err) => {
if (cancelled) return
- const { denyTemplateStorageAccess } = useEditorStore.getState()
- applyTemplateStorageAccessFailure(err, { denyTemplateStorageAccess })
+ const { denyTemplateStorageAccessAndReset } = useEditorStore.getState()
+ applyTemplateStorageAccessFailure(err, {
+ denyTemplateStorageAccessAndReset,
+ })
setActiveRecord(null)
})
@@ -159,7 +161,15 @@ export const Header = ({
label="Settings"
icon={ }
variant="icon"
- onClick={() => setIsSettingsOpen(true)}
+ onClick={() => {
+ if (!templateId) return
+ setIsSettingsOpen(true)
+ }}
+ disabled={!templateId}
+ _disabled={{
+ cursor: "not-allowed",
+ opacity: 0.5,
+ }}
/>
void
+ denyTemplateStorageAccessAndReset: (message?: string) => void
}
/** Clears the loaded template and surfaces an error when access was revoked. */
@@ -43,6 +43,6 @@ export function applyTemplateStorageAccessFailure(
err instanceof TemplateAccessDeniedError
? err.message
: "You no longer have access to this template."
- actions.denyTemplateStorageAccess(message)
+ actions.denyTemplateStorageAccessAndReset(message)
return true
}
diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
index be06dcd..3d4e2fb 100644
--- a/packages/imagekit-editor-dev/src/store.ts
+++ b/packages/imagekit-editor-dev/src/store.ts
@@ -180,7 +180,17 @@ export type EditorActions<
setLastSavedAt: (ts: number | null) => void
setTransformationConfigFormDirty: (dirty: boolean) => void
resetToNewTemplate: () => void
- denyTemplateStorageAccess: (message?: string) => void
+ /**
+ * Blocks any further writes to template storage while keeping the current
+ * template state intact (so the user can keep viewing/editing locally).
+ * Intended for 401/403 write failures.
+ */
+ blockTemplateStorageWrites: (message?: string) => void
+ /**
+ * Clears the loaded template and surfaces an error when access is revoked
+ * for viewing/loading the template.
+ */
+ denyTemplateStorageAccessAndReset: (message?: string) => void
_setSidebarState: (state: "none" | "type" | "config") => void
_setSelectedTransformationKey: (key: string | null) => void
@@ -627,7 +637,15 @@ const useEditorStore = create()(
})
},
- denyTemplateStorageAccess: (message) => {
+ blockTemplateStorageWrites: (message) => {
+ set({
+ syncStatus: "error",
+ storageError: message ?? "You no longer have access to this template.",
+ templateStorageWriteBlocked: true,
+ })
+ },
+
+ denyTemplateStorageAccessAndReset: (message) => {
set({
transformations: [],
visibleTransformations: {},
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 6ca0a98..3335276 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.15",
+ "version": "2.2.0-stage-30-04-2026.18",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From 889c259e04932a3be3beab3c567555a6b7210ae8 Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Fri, 1 May 2026 13:33:29 +0530
Subject: [PATCH 63/64] chore: version bump to 3.0.0
---
packages/imagekit-editor-dev/package.json | 2 +-
packages/imagekit-editor/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json
index a06008b..0d9c7b5 100644
--- a/packages/imagekit-editor-dev/package.json
+++ b/packages/imagekit-editor-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
- "version": "2.2.0-stage-30-04-2026.18",
+ "version": "3.0.0",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json
index 3335276..4211de8 100644
--- a/packages/imagekit-editor/package.json
+++ b/packages/imagekit-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@imagekit/editor",
- "version": "2.2.0-stage-30-04-2026.18",
+ "version": "3.0.0",
"description": "Image Editor powered by ImageKit",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
From e315d57c17821c1a2697f34a879ca2acd6f1122b Mon Sep 17 00:00:00 2001
From: Harshit Budhraja
Date: Fri, 1 May 2026 13:42:02 +0530
Subject: [PATCH 64/64] chore: updated ci steps to optimize test suites and
lints
---
.github/workflows/ci.yaml | 2 +-
package.json | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index c0d7251..3ae2a6c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -25,7 +25,7 @@ jobs:
- name: 📦 Install deps, lint, test, build, pack
run: |
yarn install --frozen-lockfile
- yarn test
+ yarn lint
yarn test:coverage
yarn package
env:
diff --git a/package.json b/package.json
index 74d035c..dd6d0d6 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"package": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz",
"release": "yarn build && shx cp README.md ./packages/imagekit-editor/ && yarn workspace @imagekit/editor pack --out ../../builds/imagekit-editor-%v.tgz && yarn workspace @imagekit/editor publish",
"prepare": "husky",
+ "lint": "biome ci --files-ignore-unknown=true",
"lint:fix": "biome format --write ./ --files-ignore-unknown=true"
},
"devDependencies": {