(null);
const qrUrlRef = useRef("");
const [, setTick] = React.useState(0);
layoutRef.current = layoutElements;
+ bgLayoutRef.current = backgroundImageLayout;
onLayoutChangeRef.current = onLayoutChange;
+ onBgLayoutChangeRef.current = onBackgroundImageLayoutChange;
const sample = samplePreviewData(badgeTypeLabel);
+ const hasBgImage = Boolean(backgroundImageUrl.trim());
+ const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID;
useEffect(() => {
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
@@ -99,6 +130,36 @@ export function BadgeDesignerPreview({
const box = canvasRef.current?.getBoundingClientRect();
if (!box?.width || !box.height) return;
+ const bgDrag = bgDragRef.current;
+ if (bgDrag) {
+ e.preventDefault();
+ const dx = ((e.clientX - bgDrag.startX) / box.width) * 100;
+ const dy = ((e.clientY - bgDrag.startY) / box.height) * 100;
+ const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
+ x: bgDrag.origX + dx,
+ y: bgDrag.origY + dy,
+ });
+ bgLayoutRef.current = next;
+ onBgLayoutChangeRef.current(next);
+ return;
+ }
+
+ const bgResize = bgResizeRef.current;
+ if (bgResize) {
+ e.preventDefault();
+ const dx = ((e.clientX - bgResize.startX) / box.width) * 100;
+ const dy = ((e.clientY - bgResize.startY) / box.height) * 100;
+ const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
+ x: bgResize.origX,
+ y: bgResize.origY,
+ w: bgResize.origW + dx,
+ h: bgResize.origH + dy,
+ });
+ bgLayoutRef.current = next;
+ onBgLayoutChangeRef.current(next);
+ return;
+ }
+
const drag = dragRef.current;
if (drag) {
const el = layoutRef.current.find((x) => x.id === drag.id);
@@ -106,7 +167,7 @@ export function BadgeDesignerPreview({
e.preventDefault();
const dx = ((e.clientX - drag.startX) / box.width) * 100;
const dy = ((e.clientY - drag.startY) / box.height) * 100;
- const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy);
+ const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio);
patchElement(drag.id, pos);
return;
}
@@ -126,6 +187,8 @@ export function BadgeDesignerPreview({
const end = () => {
dragRef.current = null;
resizeRef.current = null;
+ bgDragRef.current = null;
+ bgResizeRef.current = null;
};
window.addEventListener("pointermove", onMove, { passive: false });
@@ -136,10 +199,10 @@ export function BadgeDesignerPreview({
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
};
- }, [patchElement]);
+ }, [patchElement, badgeAspectRatio]);
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
- if (resizeRef.current) return;
+ if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return;
e.preventDefault();
e.stopPropagation();
onSelect(el.id);
@@ -165,52 +228,151 @@ export function BadgeDesignerPreview({
};
};
- const bgStyle: React.CSSProperties =
- backgroundMode === "image" && backgroundImageUrl.trim()
- ? {
- backgroundImage: `url(${backgroundImageUrl})`,
- backgroundSize: "cover",
- backgroundPosition: "center",
- }
- : { backgroundColor };
+ const startBgDrag = (e: React.PointerEvent) => {
+ if (bgResizeRef.current || resizeRef.current) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onSelect(BADGE_BACKGROUND_IMAGE_ID);
+ bgDragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ origX: backgroundImageLayout.x,
+ origY: backgroundImageLayout.y,
+ };
+ };
+
+ const startBgResize = (e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onSelect(BADGE_BACKGROUND_IMAGE_ID);
+ bgDragRef.current = null;
+ bgResizeRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ origX: backgroundImageLayout.x,
+ origY: backgroundImageLayout.y,
+ origW: backgroundImageLayout.w,
+ origH: backgroundImageLayout.h,
+ };
+ };
return (
- Live preview — drag to move; drag corner handle to resize
+ Live preview — drag fields to move; drag corner handles to resize
+ {hasBgImage
+ ? "; drag the background image to pan (it may extend outside the badge), resize to zoom"
+ : ""}
onSelect(null)}
+ ref={surfaceRef}
style={{
+ position: "relative",
width: "100%",
maxWidth: 420,
- aspectRatio: String(aspectRatio),
- position: "relative",
- borderRadius: "var(--radius-button)",
- border: "1px solid var(--border)",
- boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
- overflow: "hidden",
- containerType: "size",
- touchAction: "none",
- ...bgStyle,
- cursor: "default",
- userSelect: "none",
}}
>
- {layoutElements.map((el) => (
-
- ))}
+
onSelect(null)}
+ style={{
+ width: "100%",
+ aspectRatio: String(aspectRatio),
+ position: "relative",
+ borderRadius: "var(--radius-button)",
+ border: "1px solid var(--border)",
+ boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
+ overflow: "hidden",
+ containerType: "size",
+ touchAction: "none",
+ backgroundColor,
+ cursor: "default",
+ userSelect: "none",
+ }}
+ >
+ {hasBgImage ? (
+
{
+ e.stopPropagation();
+ onSelect(BADGE_BACKGROUND_IMAGE_ID);
+ startBgDrag(e);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ position: "absolute",
+ left: `${backgroundImageLayout.x}%`,
+ top: `${backgroundImageLayout.y}%`,
+ width: `${backgroundImageLayout.w}%`,
+ height: `${backgroundImageLayout.h}%`,
+ boxSizing: "border-box",
+ cursor: bgSelected ? "grab" : "pointer",
+ touchAction: "none",
+ zIndex: 0,
+ overflow: "hidden",
+ }}
+ >
+

+
+ ) : null}
+ {layoutElements.map((el) => (
+
+ ))}
+
+ {hasBgImage && bgSelected ? (
+
+ ) : null}
);
@@ -252,6 +414,7 @@ function PreviewElement({
cursor: "grab",
boxSizing: "border-box",
touchAction: "none",
+ zIndex: 1,
...extra,
}}
>
@@ -299,6 +462,7 @@ function PreviewElement({
touchAction: "none",
overflow: "hidden",
background: "#fff",
+ zIndex: 1,
}}
>
{url ? (
@@ -481,6 +645,7 @@ function TextPreviewBlock({
boxSizing: "border-box",
textAlign: align,
touchAction: "none",
+ zIndex: 1,
}}
>
) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@@ -76,6 +78,7 @@ export function BadgeFieldInspector({
element={element}
customImages={customImages}
badgeAspectRatio={badgeAspectRatio}
+ mediaEventId={mediaEventId}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
onCustomImageUploaded={onCustomImageUploaded}
@@ -224,6 +227,7 @@ function ResizableInspector({
element,
customImages,
badgeAspectRatio,
+ mediaEventId = "portfolio",
onChange,
onRemoveCustom,
onCustomImageUploaded,
@@ -231,6 +235,7 @@ function ResizableInspector({
element: BadgeLayoutElement;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
+ mediaEventId?: string;
onChange: (patch: Partial) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@@ -250,7 +255,7 @@ function ResizableInspector({
setUploading(true);
try {
const imageAspectRatio = await readFileImageAspectRatio(file);
- const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
+ const r = await uploadMediaFile(file, { eventId: mediaEventId, module: "badges/fixed-images" });
if (r.url) {
const w = element.w ?? 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
diff --git a/src/app/components/panels/CreateBadgePanel.tsx b/src/app/components/panels/CreateBadgePanel.tsx
index 8925b0d9..78ac9f5c 100644
--- a/src/app/components/panels/CreateBadgePanel.tsx
+++ b/src/app/components/panels/CreateBadgePanel.tsx
@@ -19,18 +19,21 @@ import {
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
import { BadgeFieldInspector } from "./BadgeFieldInspector";
-import type { BadgeLayoutElement } from "./badgeLayout";
+import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import {
+ BADGE_BACKGROUND_IMAGE_ID,
MAX_CUSTOM_IMAGE_FIELDS,
MAX_CUSTOM_TEXT_FIELDS,
applyElementSize,
buildCustomImageElement,
clampElementPosition,
+ defaultBackgroundImageLayout,
defaultLayoutForFields,
heightPctForImageWidth,
newCustomImageId,
newCustomTextId,
readFileImageAspectRatio,
+ squareElementHeightPct,
syncLayoutWithFields,
} from "./badgeLayout";
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
@@ -179,9 +182,11 @@ export function CreateBadgePanel({
const [includeParticipants, setIncludeParticipants] = useState(true);
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
const [participantListId, setParticipantListId] = useState("");
- const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
+ const [backgroundImageLayout, setBackgroundImageLayout] = useState(
+ defaultBackgroundImageLayout,
+ );
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
const [enabledFields, setEnabledFields] = useState(["name", "title", "org", "qr"]);
const [layoutElements, setLayoutElements] = useState(() =>
@@ -199,6 +204,9 @@ export function CreateBadgePanel({
const [headerBusy, setHeaderBusy] = useState(false);
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
+ const badgeSurfaceRef = useRef(null);
+ const badgeInspectorRef = useRef(null);
+
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
const isDuplicate = Boolean(duplicateFromBatchId);
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
@@ -208,6 +216,18 @@ export function CreateBadgePanel({
[layoutElements, selectedElementId],
);
+ useEffect(() => {
+ if (!selectedElementId) return;
+ const dismissSelection = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (badgeSurfaceRef.current?.contains(target)) return;
+ if (badgeInspectorRef.current?.contains(target)) return;
+ setSelectedElementId(null);
+ };
+ document.addEventListener("pointerdown", dismissSelection);
+ return () => document.removeEventListener("pointerdown", dismissSelection);
+ }, [selectedElementId]);
+
useEffect(() => {
return () => {
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
@@ -220,14 +240,14 @@ export function CreateBadgePanel({
let merged = { ...el };
if (el.field === "qr") {
const w = el.w ?? 28;
- merged = { ...merged, w, h: w };
+ merged = { ...merged, w, h: squareElementHeightPct(w, aspectRatio) };
} else if (el.field === "photo" && el.w != null) {
- merged = { ...merged, h: el.w };
+ merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) };
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
const w = el.w ?? 25;
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
}
- return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) };
+ return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) };
}),
);
}, [sizePreset, orientation, customW, customH, aspectRatio]);
@@ -258,9 +278,9 @@ export function CreateBadgePanel({
setIncludeParticipants(s.includeParticipants);
setParticipantScope(s.participantScope);
setParticipantListId(s.participantListId);
- setBackgroundMode(s.backgroundMode);
setBackgroundColor(s.backgroundColor);
setBackgroundImageUrl(s.backgroundImageUrl);
+ setBackgroundImageLayout(s.backgroundImageLayout);
setBackgroundPreviewUrl("");
setEnabledFields(s.enabledFields);
setLayoutElements(s.layoutElements);
@@ -302,7 +322,7 @@ export function CreateBadgePanel({
: [...enabledFields, id];
setEnabledFields(next);
setLayoutElements((prev) =>
- syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio),
+ syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, "color", aspectRatio),
);
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
};
@@ -314,11 +334,13 @@ export function CreateBadgePanel({
const nextCustom = [...customTexts, { id, text }];
setCustomTexts(nextCustom);
setLayoutElements((prev) =>
- syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio),
+ syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio),
);
setSelectedElementId(id);
};
+ const badgeMediaEventId = formEventId.trim() || "portfolio";
+
const addCustomImage = async (file: File) => {
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
if (!file.type.startsWith("image/")) {
@@ -330,7 +352,7 @@ export function CreateBadgePanel({
setErr("");
try {
const imageAspectRatio = await readFileImageAspectRatio(file);
- const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
+ const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/fixed-images" });
if (!r.url) throw new Error("Upload failed");
const nextImages = [...customImages, { id, imageUrl: r.url }];
setCustomImages(nextImages);
@@ -398,11 +420,13 @@ export function CreateBadgePanel({
setBackgroundPreviewUrl(local);
setHeaderBusy(true);
setErr("");
+ const hadBgImage = Boolean(backgroundImageUrl.trim());
try {
- const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" });
+ const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
if (r.url) {
setBackgroundImageUrl(r.url);
- setBackgroundMode("image");
+ if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
+ setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
}
} catch (e) {
setErr(e instanceof Error ? e.message : "Upload failed");
@@ -434,10 +458,6 @@ export function CreateBadgePanel({
setErr("Select a contact list.");
return;
}
- if (backgroundMode === "image" && !backgroundImageUrl.trim()) {
- setErr("Upload a background image or switch to solid colour.");
- return;
- }
const hasCustom =
layoutElements.some((el) => el.kind === "custom_text") ||
layoutElements.some((el) => el.kind === "custom_image");
@@ -495,8 +515,9 @@ export function CreateBadgePanel({
includeParticipants,
participantScope,
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
- backgroundMode,
- backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "",
+ backgroundMode: backgroundImageUrl.trim() ? "image" : "color",
+ backgroundImageUrl: backgroundImageUrl.trim(),
+ ...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}),
backgroundColor,
fields: enabledFields,
layout: layoutPayload,
@@ -548,21 +569,25 @@ export function CreateBadgePanel({
-
+ c.id === selectedElementId)?.text}
customImages={customImages}
badgeAspectRatio={aspectRatio}
+ mediaEventId={badgeMediaEventId}
onChange={updateSelectedElement}
onRemoveCustom={
selectedElement?.kind === "custom_text"
@@ -576,6 +601,7 @@ export function CreateBadgePanel({
updateSelectedElement({ imageUrl: url, imageAspectRatio });
}}
/>
+