feat: add read-only mode to editor panels and implement project map viewing functionality
Build and Release / release (push) Successful in 38s

This commit is contained in:
taDuc
2026-06-14 13:58:17 +07:00
parent 5a8dfc4b50
commit c8a16573ca
9 changed files with 1043 additions and 269 deletions
@@ -21,6 +21,7 @@ type BindingRow = {
type Props = {
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
readOnly?: boolean;
};
function wikiTitle(w: WikiSnapshot): string {
@@ -28,7 +29,7 @@ function wikiTitle(w: WikiSnapshot): string {
return t.length ? t : "Untitled wiki";
}
function EntityWikiBindingsPanel({ setLinks }: Props) {
function EntityWikiBindingsPanel({ setLinks, readOnly }: Props) {
const {
entityCatalog,
snapshotEntityRows,
@@ -189,186 +190,189 @@ function EntityWikiBindingsPanel({ setLinks }: Props) {
{collapsed ? null : (
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
<select
value={activeEntityId}
onChange={(e) => setActiveEntityId(e.target.value)}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
>
<option value="">Select entity</option>
{entityChoices.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
{activeEntityId ? (
<ActiveSelectionLabel
label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/>
) : null}
</div>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
<div style={{ display: "grid", gap: "8px" }}>
<select
value={activeWikiId}
onChange={(e) => setActiveWikiId(e.target.value)}
disabled={wikiChoices.length === 0}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
opacity: wikiChoices.length === 0 ? 0.7 : 1,
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
}}
>
<option value="">
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
</option>
{wikiChoices.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{activeWikiChoice ? (
<ActiveSelectionLabel
label={activeWikiChoice.title}
id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/>
) : null}
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : (
<>
<button
type="button"
disabled={!activeEntityId || !activeWikiId}
onClick={() => toggle(activeWikiId)}
{!readOnly && (
<>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
<select
value={activeEntityId}
onChange={(e) => setActiveEntityId(e.target.value)}
style={{
border: "none",
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
background: activeWikiLinked ? "#334155" : "#16a34a",
color: "white",
fontWeight: 800,
fontSize: 12,
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
fontSize: "12px",
outline: "none",
}}
>
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
</button>
{activeWikiChoice ? (
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
{activeWikiChoice.id}
</div>
<option value="">Select entity</option>
{entityChoices.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
{activeEntityId ? (
<ActiveSelectionLabel
label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/>
) : null}
</div>
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
key={id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "#111827",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
title={id}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</span>
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</div>
</div>
<button
type="button"
onClick={() => toggle(id)}
style={{
border: "none",
background: "#0b1220",
color: "#fecaca",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 800,
flex: "0 0 auto",
}}
>
Unlink
</button>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
<div style={{ display: "grid", gap: "8px" }}>
<select
value={activeWikiId}
onChange={(e) => setActiveWikiId(e.target.value)}
disabled={wikiChoices.length === 0}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
opacity: wikiChoices.length === 0 ? 0.7 : 1,
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
}}
>
<option value="">
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
</option>
{wikiChoices.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{activeWikiChoice ? (
<ActiveSelectionLabel
label={activeWikiChoice.title}
id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/>
) : null}
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : (
<>
<button
type="button"
disabled={!activeEntityId || !activeWikiId}
onClick={() => toggle(activeWikiId)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
background: activeWikiLinked ? "#334155" : "#16a34a",
color: "white",
fontWeight: 800,
fontSize: 12,
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
}}
>
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
</button>
{activeWikiChoice ? (
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
{activeWikiChoice.id}
</div>
);
})}
) : null}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
)}
</>
)}
</div>
</div>
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
key={id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "#111827",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
title={id}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</span>
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</div>
</div>
<button
type="button"
onClick={() => toggle(id)}
style={{
border: "none",
background: "#0b1220",
color: "#fecaca",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 800,
flex: "0 0 auto",
}}
>
Unlink
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
)}
</>
)}
</div>
</div>
</>
)}
<div
style={{
borderTop: "1px solid #1f2937",
paddingTop: 8,
display: "grid",
gap: 6,
}}
>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
All bindings ({activeBindingRows.length})
</div>
<div
style={{
borderTop: readOnly ? "none" : "1px solid #1f2937",
paddingTop: readOnly ? 0 : 8,
display: "grid",
gap: 6,
}}
>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
All bindings ({activeBindingRows.length})
</div>
{activeBindingRows.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
{activeBindingRows.map((row) => (
@@ -32,6 +32,7 @@ type Props = {
selectedGeometryChildIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void;
readOnly?: boolean;
};
function GeometryBindingPanel({
@@ -40,6 +41,7 @@ function GeometryBindingPanel({
selectedGeometryChildIds,
onToggleBindGeometryForSelectedGeometry,
onFocusGeometry,
readOnly,
}: Props) {
const {
selectedFeatureIds,
@@ -62,7 +64,7 @@ function GeometryBindingPanel({
selectedGeometryId ??
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
const canBindToggle =
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
!readOnly && Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false);
@@ -15,6 +15,7 @@ type Props = {
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
onRerollEntityId?: (oldId: string, nextId: string) => void;
onDeleteEntity?: (entityId: string) => void;
readOnly?: boolean;
};
function ProjectEntityRefsPanel({
@@ -25,6 +26,7 @@ function ProjectEntityRefsPanel({
onToggleBindEntityForSelectedGeometry,
onRerollEntityId,
onDeleteEntity,
readOnly,
}: Props) {
const {
snapshotEntityRows,
@@ -44,11 +46,12 @@ function ProjectEntityRefsPanel({
}))
);
const canBindToggle =
!readOnly &&
Boolean(hasSelectedGeometry) &&
Array.isArray(selectedGeometryEntityIds) &&
typeof onToggleBindEntityForSelectedGeometry === "function";
const canEditEntity = typeof onUpdateEntity === "function";
const canEditEntity = !readOnly && typeof onUpdateEntity === "function";
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
@@ -236,7 +239,7 @@ function ProjectEntityRefsPanel({
)}
</button>
) : null}
{typeof onDeleteEntity === "function" ? (
{!readOnly && typeof onDeleteEntity === "function" ? (
<button
type="button"
title="Xóa thực thể khỏi dự án"
@@ -420,7 +423,7 @@ function ProjectEntityRefsPanel({
</div>
) : null}
{collapsed ? null : (
{collapsed || readOnly ? null : (
<>
<div
style={{
@@ -21,6 +21,7 @@ type Props = {
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
onRerollGeometryId?: (oldId: string | number) => void;
readOnly?: boolean;
};
function SelectedGeometryPanel({
@@ -31,6 +32,7 @@ function SelectedGeometryPanel({
onDeleteFeatures,
onDeselectAll,
onRerollGeometryId,
readOnly,
}: Props) {
const {
geometryMetaForm,
@@ -156,41 +158,45 @@ function SelectedGeometryPanel({
HÀNH ĐNG NHANH
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
{!readOnly && (
<>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
</>
)}
<button
type="button"
onClick={() => onDeselectAll?.()}
@@ -204,6 +210,7 @@ function SelectedGeometryPanel({
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
gridColumn: readOnly ? "span 2" : undefined,
}}
>
Bỏ chọn tất cả
@@ -229,7 +236,7 @@ function SelectedGeometryPanel({
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
</div>
{canRerollGeometryId && onRerollGeometryId && (
{!readOnly && canRerollGeometryId && onRerollGeometryId && (
<button
type="button"
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
@@ -268,7 +275,7 @@ function SelectedGeometryPanel({
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
disabled={readOnly || isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
@@ -307,7 +314,7 @@ function SelectedGeometryPanel({
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
disabled={readOnly || isEntitySubmitting}
style={entityInputStyle}
/>
<input
@@ -319,18 +326,20 @@ function SelectedGeometryPanel({
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
disabled={readOnly || isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
{!readOnly && (
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
)}
{!readOnly && onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
@@ -359,7 +368,7 @@ function SelectedGeometryPanel({
)}
</div>
{changeCount > 0 ? (
{!readOnly && changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Thay đi sẽ vào lịch sử khi Commit.
</div>
+45 -38
View File
@@ -57,6 +57,7 @@ type Props = {
projectId: string;
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
onRemoveWiki?: (wikiId: string) => void;
readOnly?: boolean;
};
function clampTitle(title: string) {
@@ -64,7 +65,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki, readOnly }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -672,26 +673,28 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
{isNewWiki(w) ? <NewBadge /> : null}
</span>
</button>
<button
type="button"
onClick={() => removeWiki(w.id)}
style={{
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",
}}
title="Xóa wiki khỏi dự án"
aria-label={`Xóa wiki ${w.id}`}
>
<TrashIcon />
</button>
{!readOnly && (
<button
type="button"
onClick={() => removeWiki(w.id)}
style={{
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",
}}
title="Xóa wiki khỏi dự án"
aria-label={`Xóa wiki ${w.id}`}
>
<TrashIcon />
</button>
)}
</div>
))}
@@ -702,7 +705,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
</div>
)}
{collapsed ? null : (
{collapsed || readOnly ? null : (
<div
style={{
marginTop: "10px",
@@ -842,15 +845,17 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={openImportPicker}
disabled={!activeId}
title="Import HTML"
>
Import
</Button>
{!readOnly && (
<Button
size="sm"
variant="outline"
onClick={openImportPicker}
disabled={!activeId}
title="Import HTML"
>
Import
</Button>
)}
<Button
size="sm"
variant="outline"
@@ -861,11 +866,13 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
Export {wikiDocStorageFormat.toUpperCase()}
</Button>
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
Save
{readOnly ? "Close" : "Cancel"}
</Button>
{!readOnly && (
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
Save
</Button>
)}
</div>
</div>
@@ -880,7 +887,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
onChange={(e) => setWikiTitle(e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
placeholder="Wiki title"
disabled={!activeId}
disabled={readOnly || !activeId}
/>
</div>
<div>
@@ -890,7 +897,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
onChange={(e) => setWikiSlug(e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
placeholder="wiki-slug"
disabled={!activeId}
disabled={readOnly || !activeId}
/>
</div>
{wikiSaveError ? (
@@ -907,7 +914,7 @@ function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
modules={quillModules}
className="min-h-[320px] uhm-wiki-quill"
placeholder="Nhap noi dung wiki..."
readOnly={!activeId}
readOnly={readOnly || !activeId}
/>
</div>
</div>