350 lines
11 KiB
TypeScript
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 & 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)",
|
|
};
|