Shift the dark theme from neutral gray to a richer blue-tinted palette inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue card surfaces, and visible blue borders create more depth and visual layering. - Update design tokens: background, card, border, input, muted colors - Add card-glow utility (radial gradient + blue box-shadow) for card surfaces - Add panel-glow utility (top-down gradient) for tall panels like stat blocks - Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts - Give outline buttons a subtle fill instead of transparent background - Active combatant row now uses full border with glow instead of left accent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
3.6 KiB
TypeScript
137 lines
3.6 KiB
TypeScript
import { Heart, Sword } from "lucide-react";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Input } from "./ui/input";
|
|
|
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
|
|
|
interface HpAdjustPopoverProps {
|
|
readonly onAdjust: (delta: number) => void;
|
|
readonly onClose: () => void;
|
|
}
|
|
|
|
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const parent = el.parentElement;
|
|
if (!parent) return;
|
|
const trigger = parent.getBoundingClientRect();
|
|
const popover = el.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth;
|
|
let left = trigger.left;
|
|
if (left + popover.width > vw) {
|
|
left = vw - popover.width - 8;
|
|
}
|
|
if (left < 8) {
|
|
left = 8;
|
|
}
|
|
setPos({ top: trigger.bottom + 4, left });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
const parsedValue =
|
|
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
|
const isValid =
|
|
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
|
|
|
|
const applyDelta = useCallback(
|
|
(sign: -1 | 1) => {
|
|
if (inputValue === "") return;
|
|
const n = Number.parseInt(inputValue, 10);
|
|
if (Number.isNaN(n) || n <= 0) return;
|
|
onAdjust(sign * n);
|
|
onClose();
|
|
},
|
|
[inputValue, onAdjust, onClose],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") {
|
|
if (e.shiftKey) {
|
|
applyDelta(1);
|
|
} else {
|
|
applyDelta(-1);
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
},
|
|
[applyDelta, onClose],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
|
style={
|
|
pos
|
|
? { top: pos.top, left: pos.left }
|
|
: { visibility: "hidden" as const }
|
|
}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={inputValue}
|
|
placeholder="HP"
|
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
|
setInputValue(v);
|
|
}
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={!isValid}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={() => applyDelta(-1)}
|
|
title="Apply damage"
|
|
aria-label="Apply damage"
|
|
>
|
|
<Sword size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!isValid}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={() => applyDelta(1)}
|
|
title="Apply healing"
|
|
aria-label="Apply healing"
|
|
>
|
|
<Heart size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|