Beute L3 Runden 2+3 und Review-Fixes: - Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben - Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts) - Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe, Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand- Änderung, Konfliktblatt. Beute L5: - Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht, Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach Rückgängig, Folgevorschläge beim Speichern - Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“ T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite „Sprache & Datenschutz“; axe-core als exakte devDependency. Backend: NotificationsService liest Empfänger und schreibt in einer Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft. Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint, typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978). Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion. Refs #19 #20 #21 #22 #27 #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type { RefObject } from "react";
|
|
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SheetFooter } from "@/components/ui/sheet";
|
|
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
|
import { partDisplayName } from "@/lib/hive/names";
|
|
import { FRAME_TYPES, MAX_TEXT_LENGTH } from "@/lib/hive/types";
|
|
import type { FrameType, HiveCatalog, HiveState } from "@/lib/hive/types";
|
|
import type { HiveOutlineHandle } from "./hive-outline";
|
|
import { HiveSheetShell } from "./hive-sheet-shell";
|
|
import { useSheetSubmit } from "./use-sheet-submit";
|
|
import type { SubmitOps } from "./use-sheet-submit";
|
|
|
|
const FRAME_OPTIONS = choiceOptions(FRAME_TYPE_LABELS, FRAME_TYPES);
|
|
|
|
export interface FrameSheetProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
state: HiveState;
|
|
catalog: HiveCatalog;
|
|
boxId: string;
|
|
slot: number;
|
|
/** Sends the ops (source CARD); rejects on failure. */
|
|
submit: SubmitOps;
|
|
/** On close, focus returns to this frame's item in the outline. */
|
|
outlineRef: RefObject<HiveOutlineHandle | null>;
|
|
}
|
|
|
|
interface FrameFormProps {
|
|
initialType: FrameType;
|
|
initialName: string;
|
|
boxId: string;
|
|
slot: number;
|
|
submit: SubmitOps;
|
|
/** Saved, or the hive changed meanwhile (the conflict sheet takes over). */
|
|
onClose: () => void;
|
|
}
|
|
|
|
function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: FrameFormProps) {
|
|
const [type, setType] = useState<FrameType>(initialType);
|
|
const [name, setName] = useState(initialName);
|
|
const { send, pending, error } = useSheetSubmit(submit);
|
|
const trimmed = name.trim();
|
|
const changed = type !== initialType || trimmed !== initialName.trim();
|
|
|
|
async function save() {
|
|
const outcome = await send([{ op: "setFrame", boxId, slot, type, name: trimmed === "" ? null : trimmed }]);
|
|
if (outcome !== "failed") onClose();
|
|
}
|
|
|
|
return (
|
|
<form
|
|
className="flex flex-col gap-4 px-4 pb-4"
|
|
onSubmit={(event) => {
|
|
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (changed && !pending) void save();
|
|
}}
|
|
>
|
|
<div className="flex flex-col gap-1.5">
|
|
<span id={`frame-${boxId}-${slot}-type`} className="text-sm font-medium">
|
|
Art
|
|
</span>
|
|
<ChoiceGroup
|
|
options={FRAME_OPTIONS}
|
|
value={type}
|
|
onChange={setType}
|
|
labelledBy={`frame-${boxId}-${slot}-type`}
|
|
className="grid-cols-2 sm:grid-cols-3"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor={`frame-${boxId}-${slot}-name`} className="text-sm font-medium">
|
|
Name
|
|
</label>
|
|
<Input
|
|
id={`frame-${boxId}-${slot}-name`}
|
|
value={name}
|
|
maxLength={MAX_TEXT_LENGTH}
|
|
autoComplete="off"
|
|
onChange={(event) => setName(event.target.value)}
|
|
aria-describedby={`frame-${boxId}-${slot}-count`}
|
|
className="h-11"
|
|
/>
|
|
<p id={`frame-${boxId}-${slot}-count`} className="text-xs text-muted-foreground">
|
|
{name.length} / {MAX_TEXT_LENGTH}
|
|
</p>
|
|
</div>
|
|
{error ? (
|
|
<p role="alert" className="text-sm text-destructive">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
<SheetFooter className="p-0">
|
|
<Button type="submit" size="touch" disabled={!changed || pending}>
|
|
Speichern
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/** Edit one frame of a box: its type and an optional name (spec 9.4). One `setFrame` per save. */
|
|
export function FrameSheet({ open, onOpenChange, state, catalog, boxId, slot, submit, outlineRef }: FrameSheetProps) {
|
|
const box = state.stack.find((candidate) => candidate.id === boxId);
|
|
const frame = box?.frames?.find((candidate) => candidate.slot === slot);
|
|
|
|
return (
|
|
<HiveSheetShell
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={`Rähmchen ${slot}`}
|
|
description={partDisplayName(state, catalog, boxId)}
|
|
finalFocus={() => {
|
|
outlineRef.current?.focusFrame(boxId, slot);
|
|
return false;
|
|
}}
|
|
>
|
|
{frame ? (
|
|
<FrameForm
|
|
// A different frame starts a fresh draft.
|
|
key={`${boxId}#${slot}`}
|
|
initialType={frame.type}
|
|
initialName={frame.name ?? ""}
|
|
boxId={boxId}
|
|
slot={slot}
|
|
submit={submit}
|
|
onClose={() => onOpenChange(false)}
|
|
/>
|
|
) : (
|
|
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
|
Dieses Rähmchen gibt es nicht mehr.
|
|
</p>
|
|
)}
|
|
</HiveSheetShell>
|
|
);
|
|
}
|