feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)
- 3D dependencies (three, react-three-fiber, drei, postprocessing, motion), exact versions, supply-chain checked; scene loaded only on demand on the colony page (283 kB gzip), SVG stays as fallback - glass hologram per owner design 2026-10-16: true Liebig Zander shapes (grips, rebate, Wanderboden foot), cool blue edge glow, click any part -> it flies to the front while the rest stays as a faint ghost; focus shows content (feed levels, Bausperre, pockets); frames rise on hover and take their type look (colour + comb pattern) - full-bleed card with frosted overlays and glass buttons - Futtertasche (FRAME_FEEDER): new part kind occupying frame slots with a fill level, catalog row, validator rules in both ports, outline, sheets, follow-ups, settings Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c608f5d405
commit
6e13236d49
100 files changed
+9432
-209
No files matched your search
@@ -5,9 +5,12 @@ import type { RefObject } from "react";
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { freeFeederStarts } from "@/lib/hive/feeder-slots";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker";
|
||||
import type { FrameFeederChoice } from "./frame-feeder-picker";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import {
|
||||
@@ -25,6 +28,11 @@ const INSERT_OPTIONS = [
|
||||
{ value: true, label: "drin" },
|
||||
{ value: false, label: "draußen" },
|
||||
];
|
||||
// Inserts the sheet can remove, with the noun used in its buttons.
|
||||
const REMOVABLE_NOUNS: Partial<Record<HivePartKind, string>> = {
|
||||
EVAPORATOR: "Verdunster",
|
||||
FRAME_FEEDER: "Futtertasche",
|
||||
};
|
||||
const EVAPORATOR_OPTIONS = [
|
||||
{ value: false, label: "Kein Verdunster" },
|
||||
{ value: true, label: "Verdunster einsetzen" },
|
||||
@@ -42,6 +50,8 @@ export interface PartSheetProps {
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
|
||||
startWithEvaporator?: boolean;
|
||||
/** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */
|
||||
startWithFrameFeeder?: boolean;
|
||||
}
|
||||
|
||||
interface Located {
|
||||
@@ -78,18 +88,26 @@ interface PartFormProps {
|
||||
type: CatalogPartType;
|
||||
catalog: HiveCatalog;
|
||||
startWithEvaporator: boolean;
|
||||
startWithFrameFeeder: boolean;
|
||||
submit: SubmitOps;
|
||||
/** `focusId` is the part whose item should get focus once the sheet is closed. */
|
||||
onDone: (focusId: string) => void;
|
||||
}
|
||||
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameFeeder, submit, onDone }: PartFormProps) {
|
||||
const { part, host } = located;
|
||||
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
|
||||
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
|
||||
const evaporatorType =
|
||||
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
|
||||
|
||||
const frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined;
|
||||
const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : [];
|
||||
const [feederChoice, setFeederChoice] = useState<FrameFeederChoice>(() => ({
|
||||
wants: startWithFrameFeeder && feederStarts.length > 0,
|
||||
start: feederStarts[0] ?? 1,
|
||||
draft: frameFeederType ? draftOf(frameFeederType, {}) : {},
|
||||
}));
|
||||
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
|
||||
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
|
||||
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
|
||||
@@ -108,8 +126,13 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
wasConfirming.current = confirmingRemoval;
|
||||
}, [confirmingRemoval]);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null;
|
||||
// Fixed per form, so a retry after an error sends the same ids.
|
||||
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
|
||||
const [newIds] = useState(() => ({
|
||||
floorInsert: newIdempotencyKey(),
|
||||
evaporator: newIdempotencyKey(),
|
||||
frameFeeder: newIdempotencyKey(),
|
||||
}));
|
||||
|
||||
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
|
||||
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
|
||||
@@ -124,19 +147,29 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
}
|
||||
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
|
||||
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
|
||||
const feederToInsert =
|
||||
frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start)
|
||||
? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft)
|
||||
: null;
|
||||
if (frameFeederType && feederToInsert) {
|
||||
ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert));
|
||||
}
|
||||
|
||||
const valid =
|
||||
areDraftFieldsValid(type, draft) &&
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) &&
|
||||
(!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft));
|
||||
|
||||
async function save() {
|
||||
// After inserting an evaporator, focus goes to the new item.
|
||||
// After inserting an evaporator or a frame feeder, focus goes to the new item.
|
||||
const outcome = await send(ops);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
|
||||
if (outcome !== "failed") {
|
||||
onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
// An evaporator is an insert: focus goes to its box afterwards.
|
||||
// An evaporator or frame feeder is an insert: focus goes to its box afterwards.
|
||||
const outcome = await send([{ op: "removePart", id: part.id }]);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
|
||||
}
|
||||
@@ -197,6 +230,16 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{frameFeederType && feederStarts.length > 0 ? (
|
||||
<FrameFeederPicker
|
||||
boxId={part.id}
|
||||
type={frameFeederType}
|
||||
frames={part.frames ?? []}
|
||||
starts={feederStarts}
|
||||
choice={feederChoice}
|
||||
onChange={setFeederChoice}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
@@ -206,7 +249,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
{type.kind === "EVAPORATOR" ? (
|
||||
{removalNoun ? (
|
||||
confirmingRemoval ? (
|
||||
<div
|
||||
role="group"
|
||||
@@ -214,7 +257,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
Verdunster wirklich entfernen?
|
||||
{removalNoun} wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
|
||||
@@ -233,7 +276,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
|
||||
size="touch"
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Verdunster entfernen
|
||||
{removalNoun} entfernen
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
@@ -255,6 +298,7 @@ export function PartSheet({
|
||||
submit,
|
||||
outlineRef,
|
||||
startWithEvaporator = false,
|
||||
startWithFrameFeeder = false,
|
||||
}: PartSheetProps) {
|
||||
const located = locate(state, partId);
|
||||
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
|
||||
@@ -279,6 +323,7 @@ export function PartSheet({
|
||||
type={type}
|
||||
catalog={catalog}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
startWithFrameFeeder={startWithFrameFeeder}
|
||||
submit={submit}
|
||||
onDone={(id) => {
|
||||
focusAfterSave.current = id;
|
||||
|
||||
Reference in new issue
Block a user