EventBuzz_Console/src/app/components/panels/BadgeFieldInspector.tsx

350 lines
11 KiB
TypeScript

/**
* Floating field styling panel — appears beside the badge preview.
*/
import React, { useRef, useState } from "react";
import { Trash2, Upload } from "lucide-react";
import { Label, SelectWrap, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import type { BadgeLayoutElement } from "./badgeLayout";
import {
BADGE_FONT_FAMILIES,
RESIZABLE_SIZE_MAX,
RESIZABLE_SIZE_MIN,
applyElementSize,
heightPctForImageWidth,
imageUrlForElement,
isResizableElement,
readFileImageAspectRatio,
isTextElement,
usesFullWidthCenter,
type BadgeTextAlign,
} from "./badgeLayout";
import { uploadMediaFile } from "../../../services/api";
const FONT_SIZE_OPTIONS = [3, 3.5, 4, 4.5, 5, 6, 7, 8, 9, 10];
const panelStyle: React.CSSProperties = {
position: "absolute",
left: "calc(100% + 12px)",
top: 36,
width: 240,
zIndex: 10,
padding: 14,
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
boxShadow: "0 8px 28px rgba(0,0,0,0.15)",
display: "flex",
flexDirection: "column",
gap: 10,
maxHeight: "calc(100vh - 120px)",
overflowY: "auto",
};
export function BadgeFieldInspector({
element,
customTextValue,
customImages,
badgeAspectRatio,
onChange,
onRemoveCustom,
onCustomImageUploaded,
}: {
element: BadgeLayoutElement | null;
customTextValue?: string;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
}) {
if (!element) return null;
if (isTextElement(element)) {
return (
<TextInspector
element={element}
customTextValue={customTextValue}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
/>
);
}
if (isResizableElement(element)) {
return (
<ResizableInspector
element={element}
customImages={customImages}
badgeAspectRatio={badgeAspectRatio}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
onCustomImageUploaded={onCustomImageUploaded}
/>
);
}
return null;
}
function TextInspector({
element,
customTextValue,
onChange,
onRemoveCustom,
}: {
element: BadgeLayoutElement;
customTextValue?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
}) {
const fullWidth = usesFullWidthCenter(element);
const setFullWidth = (on: boolean) => {
if (on) {
onChange({
fullWidthCenter: true,
x: 0,
w: 100,
textAlign: element.textAlign ?? "center",
});
} else {
onChange({
fullWidthCenter: false,
x: element.x > 0 ? element.x : 6,
w: 72,
textAlign: element.textAlign === "center" ? "left" : element.textAlign ?? "left",
});
}
};
return (
<div role="dialog" aria-label="Selected field styling" onClick={(e) => e.stopPropagation()} style={panelStyle}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
Selected field styling
</p>
{element.kind === "custom_text" && (
<>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Text</span>
<input
style={{ ...IS, marginTop: 4 }}
value={element.text ?? customTextValue ?? ""}
onChange={(e) => onChange({ text: e.target.value })}
onFocus={focusIn}
onBlur={focusOut}
/>
</div>
{onRemoveCustom && (
<button type="button" onClick={onRemoveCustom} style={removeBtnStyle}>
<Trash2 size={12} /> Remove fixed text
</button>
)}
</>
)}
<label style={checkboxLabelStyle}>
<input type="checkbox" checked={fullWidth} onChange={(e) => setFullWidth(e.target.checked)} style={{ marginTop: 3 }} />
<span>Full width &amp; center text</span>
</label>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={element.shrinkToFit !== false}
onChange={(e) => onChange({ shrinkToFit: e.target.checked })}
style={{ marginTop: 3 }}
/>
<span>Shrink font if text overflows badge</span>
</label>
<div>
<Label text="Align" />
<RadioGroup
name="textAlign"
value={element.textAlign ?? (fullWidth ? "center" : "left")}
onChange={(v) => onChange({ textAlign: v as BadgeTextAlign })}
options={[
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
]}
/>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font colour</span>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4 }}>
<input type="color" value={element.color ?? "#0f172a"} onChange={(e) => onChange({ color: e.target.value })} style={{ width: 40, height: 32, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={element.color ?? "#0f172a"} onChange={(e) => onChange({ color: e.target.value })} onFocus={focusIn} onBlur={focusOut} />
</div>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font family</span>
<SelectWrap>
<select style={{ ...SS, marginTop: 4 }} value={element.fontFamily ?? "Inter"} onChange={(e) => onChange({ fontFamily: e.target.value })} onFocus={focusIn} onBlur={focusOut}>
{BADGE_FONT_FAMILIES.map((ff) => (
<option key={ff.id} value={ff.id}>{ff.label}</option>
))}
</select>
</SelectWrap>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font size</span>
<SelectWrap>
<select style={{ ...SS, marginTop: 4 }} value={String(element.fontSizePct ?? 5)} onChange={(e) => onChange({ fontSizePct: parseFloat(e.target.value) })} onFocus={focusIn} onBlur={focusOut}>
{FONT_SIZE_OPTIONS.map((s) => (
<option key={s} value={String(s)}>{s}%</option>
))}
</select>
</SelectWrap>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Weight</span>
<RadioGroup
name="fontWeight"
value={element.fontWeight ?? "normal"}
onChange={(v) => onChange({ fontWeight: v as "normal" | "bold" })}
options={[
{ label: "Normal", value: "normal" },
{ label: "Bold", value: "bold" },
]}
/>
</div>
<HelperText text="Drag the field on the preview to move it." />
</div>
);
}
function ResizableInspector({
element,
customImages,
badgeAspectRatio,
onChange,
onRemoveCustom,
onCustomImageUploaded,
}: {
element: BadgeLayoutElement;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const size = element.w ?? 28;
const label =
element.field === "qr" ? "QR code" : element.field === "photo" ? "Photo" : "Fixed image";
const onSizeChange = (w: number) => {
onChange(applyElementSize(element, w, badgeAspectRatio));
};
const onFile = async (file: File | undefined) => {
if (!file?.type.startsWith("image/") || element.kind !== "custom_image") return;
setUploading(true);
try {
const imageAspectRatio = await readFileImageAspectRatio(file);
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
if (r.url) {
const w = element.w ?? 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
onChange({ imageUrl: r.url, imageAspectRatio, h });
onCustomImageUploaded?.(element.id, r.url, imageAspectRatio);
}
} finally {
setUploading(false);
}
};
const imgUrl = element.kind === "custom_image" ? imageUrlForElement(element, customImages) : "";
return (
<div role="dialog" aria-label="Selected element styling" onClick={(e) => e.stopPropagation()} style={panelStyle}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{label}
</p>
{element.kind === "custom_image" && (
<>
{imgUrl ? (
<img src={imgUrl} alt="" style={{ maxWidth: "100%", maxHeight: 80, objectFit: "contain", borderRadius: 4, border: "1px solid var(--border)" }} />
) : null}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} disabled={uploading} onChange={(e) => { void onFile(e.target.files?.[0]); e.target.value = ""; }} />
<button type="button" disabled={uploading} onClick={() => fileRef.current?.click()} style={uploadBtnStyle}>
<Upload size={14} />
{uploading ? "Uploading…" : imgUrl ? "Replace image" : "Upload image"}
</button>
{onRemoveCustom && (
<button type="button" onClick={onRemoveCustom} style={removeBtnStyle}>
<Trash2 size={12} /> Remove fixed image
</button>
)}
</>
)}
<div>
<Label text="Size" />
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6 }}>
<input
type="range"
min={RESIZABLE_SIZE_MIN}
max={RESIZABLE_SIZE_MAX}
step={1}
value={size}
onChange={(e) => onSizeChange(parseFloat(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", minWidth: 36 }}>
{Math.round(size)}%
</span>
</div>
</div>
<HelperText text="Drag to move. Use the blue corner handle on the preview or the slider above to resize." />
</div>
);
}
const checkboxLabelStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: "var(--text-sm)",
cursor: "pointer",
};
const removeBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
border: "1px solid var(--destructive)",
color: "var(--destructive)",
background: "transparent",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-xs)",
alignSelf: "flex-start",
};
const uploadBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
};