add somenew UI editor feature for more effêcncy

This commit is contained in:
taDuc
2026-05-20 02:14:56 +07:00
parent 488eee1a25
commit 194b3ad3c2
36 changed files with 2608 additions and 597 deletions
@@ -8,6 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: number | null;
time_end?: number | null;
isTimelineVisible?: boolean;
isNew?: boolean;
};
@@ -31,16 +34,16 @@ export default function GeometryBindingPanel({
statusText,
bindingFilterEnabled,
setGeometryBindingFilterEnabled,
hoveredGeometryId,
setHoveredGeometryId,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
hoveredGeometryId: state.hoveredGeometryId,
setHoveredGeometryId: state.setHoveredGeometryId,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const effectiveSelectedGeometryId =
@@ -55,7 +58,14 @@ export default function GeometryBindingPanel({
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: typeof g.time_start === "number" ? g.time_start : null,
time_end: typeof g.time_end === "number" ? g.time_end : null,
isTimelineVisible: Boolean(g.isTimelineVisible),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned;
}, [geometries]);
@@ -80,15 +90,20 @@ export default function GeometryBindingPanel({
if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId);
};
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return (
<div
style={{
@@ -97,7 +112,6 @@ export default function GeometryBindingPanel({
borderRadius: "8px",
border: "1px solid #1f2937",
}}
onMouseLeave={() => setHoveredGeometryId(null)}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
@@ -148,31 +162,28 @@ export default function GeometryBindingPanel({
</div>
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const idColor = getGeometryIdColor(selectedGeometry);
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border:
hoveredGeometryId === selectedGeometry.id
? "1px solid rgba(245, 158, 11, 0.95)"
: "1px solid rgba(59, 130, 246, 0.45)",
background:
hoveredGeometryId === selectedGeometry.id
? "rgba(245, 158, 11, 0.18)"
: "rgba(37, 99, 235, 0.12)",
"1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
boxShadow:
hoveredGeometryId === selectedGeometry.id
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
: "none",
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={selectedGeometry.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
>
<div
style={{
@@ -190,7 +201,7 @@ export default function GeometryBindingPanel({
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
@@ -199,13 +210,26 @@ export default function GeometryBindingPanel({
>
{selectedGeometry.label || selectedGeometry.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<div
style={{
marginTop: 3,
fontSize: "11px",
color: "#94a3b8",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -214,6 +238,8 @@ export default function GeometryBindingPanel({
{selectedGeometry.id}
</div>
</div>
);
})()
) : null}
{collapsed ? null : rows.length ? (
@@ -221,36 +247,33 @@ export default function GeometryBindingPanel({
{visibleRows
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHovered = hoveredGeometryId === g.id;
const isHidden = geometryVisibility[g.id] === false;
const idColor = getGeometryIdColor(g);
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isHovered
? "1px solid rgba(245, 158, 11, 0.95)"
: isBound
border: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isHovered
? "rgba(245, 158, 11, 0.18)"
: isBound
background: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75,
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={g.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
onMouseEnter={() => setHoveredGeometryId(g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -264,7 +287,7 @@ export default function GeometryBindingPanel({
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
@@ -273,13 +296,14 @@ export default function GeometryBindingPanel({
>
{g.label || g.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null}
</div>
<div
style={{
fontSize: "11px",
color: "#94a3b8",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -289,6 +313,19 @@ export default function GeometryBindingPanel({
</div>
</div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? (
<button
type="button"
@@ -356,6 +393,74 @@ const boundBadgeStyle: CSSProperties = {
letterSpacing: 0,
};
const hiddenBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function getGeometryIdColor(geometry: GeometryChoice): string {
const hasStart = typeof geometry.time_start === "number";
const hasEnd = typeof geometry.time_end === "number";
if (!hasStart && !hasEnd) return "#f87171";
if (!hasStart || !hasEnd) return "#facc15";
return "#94a3b8";
}
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -0,0 +1,127 @@
"use client";
import type { ChangeEvent } from "react";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
overlay: MapImageOverlay | null;
onPickImage: (file: File | null) => void;
onPasteImage: () => void;
keyboardEnabled: boolean;
onKeyboardEnabledChange: (enabled: boolean) => void;
onOpacityChange: (opacity: number) => void;
onRemove: () => void;
};
export default function ImageOverlayPanel({
overlay,
onPickImage,
onPasteImage,
keyboardEnabled,
onKeyboardEnabledChange,
onOpacityChange,
onRemove,
}: Props) {
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
onPickImage(file);
event.target.value = "";
};
return (
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
Chuột phải kéo điểm vàng đ di chuyển, điểm xanh đ kéo dãn giữ ratio.
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
</div>
</div>
{overlay ? (
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
Remove
</button>
) : null}
</div>
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
<label style={uploadButtonStyle}>
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
Paste nh
</button>
<button
type="button"
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
disabled={!overlay}
style={{
...uploadButtonStyle,
opacity: overlay ? 1 : 0.5,
color: keyboardEnabled ? "#86efac" : "#93c5fd",
cursor: overlay ? "pointer" : "not-allowed",
}}
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
>
Keys: {keyboardEnabled ? "On" : "Off"}
</button>
</div>
{overlay ? (
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
{overlay.name}
</div>
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={overlay.opacity}
onChange={(event) => onOpacityChange(Number(event.target.value))}
style={{ width: "100%", accentColor: "#38bdf8" }}
/>
</label>
</div>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
Chưa nh overlay.
</div>
)}
</section>
);
}
const uploadButtonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #334155",
borderRadius: 6,
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "7px 10px",
} as const;
const dangerButtonStyle = {
border: "1px solid #7f1d1d",
borderRadius: 6,
background: "#1f1111",
color: "#fecaca",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "6px 8px",
} as const;
@@ -8,8 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
hasSelectedGeometry?: boolean;
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
};
@@ -17,6 +18,7 @@ export default function ProjectEntityRefsPanel({
onCreateEntityOnly,
onUpdateEntity,
hasSelectedGeometry,
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry,
}: Props) {
const {
@@ -78,15 +80,35 @@ export default function ProjectEntityRefsPanel({
);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editTimeStart, setEditTimeStart] = useState("");
const [editTimeEnd, setEditTimeEnd] = useState("");
const canCopySelectedGeometryTime =
selectedGeometryTime != null &&
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description));
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
};
const handleEntityFormChange = (key: "name" | "description", value: string) => {
const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const copySelectedGeometryTimeToCreateForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEntityForm((prev) => ({
...prev,
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
}));
};
const copySelectedGeometryTimeToEditForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
};
return (
<div
@@ -237,27 +259,27 @@ export default function ProjectEntityRefsPanel({
</span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
<CloseIcon />
</button>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={copySelectedGeometryTimeToEditForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={iconButtonStyle(false)}
>
<CloseIcon />
</button>
</div>
</div>
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
@@ -277,10 +299,31 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={editTimeStart}
onChange={(event) => setEditTimeStart(event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editTimeEnd}
onChange={(event) => setEditTimeEnd(event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
border: "none",
@@ -315,29 +358,30 @@ export default function ProjectEntityRefsPanel({
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới
</div>
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
opacity: isEntitySubmitting ? 0.6 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isCreateOpen ? (
<button
type="button"
onClick={copySelectedGeometryTimeToCreateForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity moi"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
) : null}
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={iconButtonStyle(isEntitySubmitting)}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
</div>
{isCreateOpen ? (
@@ -356,6 +400,22 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={entityForm.time_start}
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.time_end}
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
@@ -403,6 +463,29 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px",
};
const timeInputGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
};
function iconButtonStyle(disabled: boolean): CSSProperties {
return {
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
};
}
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
@@ -488,3 +571,12 @@ function CloseIcon() {
</svg>
);
}
function ClockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}