fix: Beute-Editor und Durchsicht nach Browser-Prüfung

- Umbauen: Fokus nach Rückgängig/Wiederholen am Ende auf den anderen
  Verlaufsknopf, nach Entfernen auf die Nachbarzeile (sonst Überschrift)
- Umbauen: Hinweis „Beute wurde inzwischen geändert“ zuverlässig, der
  Stand wird bei Fokus/Sichtbarkeit und alle 30 s neu geladen, solange
  ein Entwurf besteht; Editor-Wurzel ist <main>
- Verlauf: Futtertaschen-Füllstand als „Flüssigfutter 1,5 l“, Entfernen
  als „Futtertasche entfernt“; Gliederung ohne leere Werte („Boden · /“)
- Durchsicht: Rückgängig der letzten Änderung behält den Schalter
  „Liste“ und setzt den Fokus auf die Überschrift der Änderungen

Refs #21 #22

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:17 +02:00
1 parent 7a07e1cac4
commit 8166e7c621
12 files changed
+253 -36

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import type { ReactNode, Ref } from "react";
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
@@ -10,6 +10,8 @@ export interface EditorToolbarProps {
title: string;
canUndo: boolean;
canRedo: boolean;
undoRef?: Ref<HTMLButtonElement>;
redoRef?: Ref<HTMLButtonElement>;
onUndo: () => void;
onRedo: () => void;
date: string;
@@ -26,19 +28,20 @@ export interface EditorToolbarProps {
}
/** Top bar of the editor: back, title, undo/redo, date and the three ways to finish. */
export function EditorToolbar(props: EditorToolbarProps) {
export function EditorToolbar({ undoRef, redoRef, ...props }: EditorToolbarProps) {
const { date, today, hasOps, saving } = props;
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
return (
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
{props.back}
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
<button type="button" onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
<button type="button" ref={undoRef} onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
<Undo2 aria-hidden />
Rückgängig
</button>
<button
type="button"
ref={redoRef}
onClick={props.onRedo}
disabled={!props.canRedo}
className={GLASS_BUTTON}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -36,13 +36,43 @@ export interface HiveEditorProps {
catalog: HiveCatalog;
/** hive-systems:manage: may store the build as the system template. */
canManageTemplates: boolean;
/** Reloads the setup; called on focus and every 30 s while a draft exists, so a change elsewhere shows. */
onRefresh?: () => void;
}
const REFRESH_INTERVAL_MS = 30_000;
const noop = () => {};
const STALE_HINT =
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
/** Row order of the stack list (top to bottom, inserts under their host). */
function listOrder(state: HiveState): string[] {
return [...state.stack].reverse().flatMap((part) => [part.id, ...(part.inserts ?? []).map((insert) => insert.id)]);
}
/** Rows to focus after `id` is removed: the next one, else the previous one (never a removed one). */
function neighbourIds(state: HiveState, id: string): string[] {
const order = listOrder(state);
const host = state.stack.find((part) => part.id === id);
const gone = new Set([id, ...(host?.inserts ?? []).map((insert) => insert.id)]);
const index = order.indexOf(id);
const after = order.slice(index + 1).filter((other) => !gone.has(other));
const before = order.slice(0, index).reverse().filter((other) => !gone.has(other));
return [...(after[0] ? [after[0]] : []), ...(before[0] ? [before[0]] : [])];
}
function rowControl(id: string): HTMLElement | null {
return document.querySelector<HTMLElement>(`[data-part-id="${CSS.escape(id)}"]`);
}
/** The history button that still works, else the stack heading. */
function historyFocusTarget(used: HTMLElement | null, other: HTMLElement | null, heading: HTMLElement | null): HTMLElement | null {
const enabled = (element: HTMLElement | null) => (element instanceof HTMLButtonElement && !element.disabled ? element : null);
return enabled(used) ?? enabled(other) ?? heading;
}
function hasPart(state: HiveState, id: string): boolean {
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
}
@@ -66,7 +96,7 @@ export function HiveEditor(props: HiveEditorProps) {
);
}
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEditorProps) {
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefresh }: HiveEditorProps) {
const router = useRouter();
const queryClient = useQueryClient();
const draft = useHiveDraft(setup.state, catalog);
@@ -86,6 +116,12 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
);
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
const undoRef = useRef<HTMLButtonElement>(null);
const redoRef = useRef<HTMLButtonElement>(null);
const stackHeadingRef = useRef<HTMLHeadingElement>(null);
// What to focus once the next render has settled (keys of rows, buttons that ran out).
const pendingFocus = useRef<(() => HTMLElement | null) | null>(null);
const hasOps = draft.ops.length > 0;
// A selection ends with its part (removed or undone).
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
@@ -110,7 +146,20 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
},
[draft, catalog, announce],
);
const apply = useCallback((op: HiveOp | HiveOp[]) => applyStep(op, false), [applyStep]);
const apply = useCallback(
(op: HiveOp | HiveOp[]) => {
const ops = Array.isArray(op) ? op : [op];
const removal = ops.length === 1 && ops[0].op === "removePart" ? ops[0] : null;
const targets = removal ? neighbourIds(draft.state, removal.id) : [];
const outcome = applyStep(op, false);
if (outcome.ok && removal) {
pendingFocus.current = () =>
targets.map(rowControl).find((element) => element !== null) ?? stackHeadingRef.current;
}
return outcome;
},
[applyStep, draft.state],
);
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
const { undo: undoDraft, redo: redoDraft } = draft;
@@ -120,13 +169,48 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
);
const undo = useCallback(() => {
const step = undoDraft();
if (step) announce(describeStep("Rückgängig", step));
if (!step) return;
announce(describeStep("Rückgängig", step));
pendingFocus.current = () => historyFocusTarget(undoRef.current, redoRef.current, stackHeadingRef.current);
}, [undoDraft, announce, describeStep]);
const redo = useCallback(() => {
const step = redoDraft();
if (step) announce(describeStep("Wiederholt", step));
if (!step) return;
announce(describeStep("Wiederholt", step));
pendingFocus.current = () => historyFocusTarget(redoRef.current, undoRef.current, stackHeadingRef.current);
}, [redoDraft, announce, describeStep]);
// A button that ran out of steps (or a removed row) must not take the keyboard focus with it.
useEffect(() => {
const resolve = pendingFocus.current;
if (!resolve) return;
pendingFocus.current = null;
const active = document.activeElement;
const lost = !active || active === document.body || (active instanceof HTMLButtonElement && active.disabled);
if (lost || !active?.isConnected) resolve()?.focus();
});
// Learn about changes made elsewhere: refresh on focus and, while a draft exists, every 30 s.
const refreshRef = useRef(onRefresh);
useEffect(() => {
refreshRef.current = onRefresh;
});
const canRefresh = onRefresh !== undefined;
useEffect(() => {
if (!canRefresh) return;
const refresh = () => {
if (document.visibilityState !== "hidden") refreshRef.current?.();
};
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
const timer = hasOps ? window.setInterval(refresh, REFRESH_INTERVAL_MS) : undefined;
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
if (timer !== undefined) window.clearInterval(timer);
};
}, [canRefresh, hasOps]);
// Leaving the page (reload, close, outside link) with open changes asks first.
useEffect(() => {
if (!hasOps) return;
@@ -199,7 +283,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
}
return (
<div className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<main className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<EditorToolbar
back={
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
@@ -210,6 +294,8 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
title="Umbauen"
canUndo={draft.canUndo}
canRedo={draft.canRedo}
undoRef={undoRef}
redoRef={redoRef}
onUndo={undo}
onRedo={redo}
date={date}
@@ -254,7 +340,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
<PartPalette catalog={catalog} onAdd={openAdd} />
</section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" className="text-base font-semibold">
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
Aufbau
</h2>
<EditorStackList
@@ -309,6 +395,6 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEd
onConfirm={() => templateMutation.mutate()}
/>
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
</div>
</main>
);
}
@@ -57,6 +57,9 @@ export interface HiveCardProps {
* loaded hive; ignored in view mode.
*/
initialOps?: HiveOp[];
/** Start with the outline list shown, and report its toggle, so a host that remounts the card can keep it. */
initialShowOutline?: boolean;
onShowOutlineChange?: (shown: boolean) => void;
}
/**
@@ -86,7 +89,7 @@ function PlainCardBody({ children }: { children: ReactNode }) {
);
}
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps }: HiveCardProps) {
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps, initialShowOutline, onShowOutlineChange }: HiveCardProps) {
const { hasPermission } = useAuth();
// Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
const hive = useHiveSetup(colonyId);
@@ -144,6 +147,8 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
mode={mode}
onOpsChange={onOpsChange}
initialOps={initialOps}
initialShowOutline={initialShowOutline}
onShowOutlineChange={onShowOutlineChange}
/>
);
}
@@ -207,6 +212,8 @@ interface HiveCardBodyProps {
mode: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
initialOps?: HiveOp[];
initialShowOutline?: boolean;
onShowOutlineChange?: (shown: boolean) => void;
}
/** The seed of the inspection draft: the given ops when they apply to the loaded hive, else none. */
@@ -215,7 +222,7 @@ function initialDraft(mode: "view" | "inspection", setup: HiveSetupView, catalog
return applyHiveOps(setup.state, ops, catalog).ok ? ops : [];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps }: HiveCardBodyProps) {
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps, initialShowOutline = false, onShowOutlineChange }: HiveCardBodyProps) {
const view = useHiveView();
const connectivity = useConnectivity();
const mutation = useHiveMutation(colonyId, catalog);
@@ -236,7 +243,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedDropped]);
const [past, setPast] = useState<PastState | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [showOutline, setShowOutline] = useState(initialShowOutline);
const [historyOpen, setHistoryOpen] = useState(false);
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
const [frameOpen, setFrameOpen] = useState(false);
@@ -380,7 +387,10 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
aria-pressed={showOutline}
aria-controls={outlineId}
aria-label="Aufbau als Liste"
onClick={() => setShowOutline(!showOutline)}
onClick={() => {
setShowOutline(!showOutline);
onShowOutlineChange?.(!showOutline);
}}
className={GLASS_BUTTON}
>
<ListTree aria-hidden />
@@ -5,9 +5,9 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { berlinCalendarDay } from "@/lib/dates";
import { fetchHiveAt, fetchHiveHistory, hiveKeys } from "@/lib/hive/api";
import { describeHiveOps } from "@/lib/hive/describe";
import { describeHiveOps, partsAddedBy } from "@/lib/hive/describe";
import { shortGermanDay, sourceLabel } from "@/lib/hive/summary";
import type { HiveCatalog, HiveChangeListItem, HiveOp, HiveState } from "@/lib/hive/types";
import type { HiveCatalog, HiveChangeListItem, HiveOp, HivePart, HiveState } from "@/lib/hive/types";
const LOAD_FAILED_TEXT = "Stand konnte nicht geladen werden.";
@@ -21,10 +21,14 @@ export function useHiveHistory(colonyId: string) {
});
}
function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog): string {
const ops = Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
// Items carry no state before; the current state names parts that still exist.
const text = describeHiveOps(ops, state, catalog);
function opsOf(item: HiveChangeListItem): HiveOp[] {
return Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
}
function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog, seen: HivePart[]): string {
// Items carry no state before; the current state names parts that still exist,
// the parts other changes added name the ones that were removed since.
const text = describeHiveOps(opsOf(item), state, catalog, seen);
if (text) return text;
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
}
@@ -44,6 +48,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
const seen = items.flatMap((item) => partsAddedBy(opsOf(item)));
async function show(item: HiveChangeListItem) {
setLoadingId(item.id);
@@ -94,7 +99,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
<span className="font-medium">
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
</span>
<span>{textOf(item, state, catalog)}</span>
<span>{textOf(item, state, catalog, seen)}</span>
{isLate ? (
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
) : null}
@@ -60,6 +60,21 @@ describe("HiveOutline", () => {
expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1")));
});
it("shows no separator or empty value for a part whose state is not set", () => {
const emptyCatalog = inlineCatalog();
const floor = emptyCatalog.partTypes.get("floor")!;
emptyCatalog.partTypes.set("floor", {
...floor,
stateSchema: [
{ key: "a", label: "A", kind: "choice", options: ["X"], optionLabels: ["x"] },
{ key: "b", label: "B", kind: "choice", options: ["Y"], optionLabels: ["y"] },
],
});
setup({ catalog: emptyCatalog, state: { schemaVersion: 1, stack: [part("f", "floor"), part("r", "roof")] } });
expect(names().find((t) => t?.startsWith("Boden"))).toBe("Boden");
});
it("shows a short state hint per part", () => {
setup();
const text = names().join("|");
@@ -49,9 +49,11 @@ function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
if (!type) return null;
if (type.stateSchema.length > 0) {
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
}
// Fields without a value (a floor's unset choices) add nothing; no stray separator.
const fieldTexts = type.stateSchema
.map((field) => fieldText(field, part.state[field.key]))
.filter((text) => text !== "");
if (fieldTexts.length > 0) return fieldTexts.join(" / ");
if (type.kind === "BOX") {
const count = part.frames?.length ?? 0;
return count > 0 ? `${count} Rähmchen` : null;