Files
initiative/apps/web/src/App.tsx
Lukas 64a1f0b8db Add mobile foundation: iOS zoom fix, safe area insets, row padding
- Input base font 16px on mobile to prevent iOS Safari auto-zoom
- Safe area insets for notched phones (top/bottom bars)
- viewport-fit=cover to enable safe area env() values
- Action bar flex-wrap for custom stat field overflow
- Slightly increased row padding on mobile (py-3 sm:py-2)
- Removed redundant font-size classes from Input usages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:19:10 +01:00

141 lines
4.3 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar.js";
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
import { CombatantRow } from "./components/combatant-row.js";
import {
PlayerCharacterSection,
type PlayerCharacterSectionHandle,
} from "./components/player-character-section.js";
import { SettingsModal } from "./components/settings-modal.js";
import { StatBlockPanel } from "./components/stat-block-panel.js";
import { Toast } from "./components/toast.js";
import { TurnNavigation } from "./components/turn-navigation.js";
import { useEncounterContext } from "./contexts/encounter-context.js";
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
import { useSidePanelContext } from "./contexts/side-panel-context.js";
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
import { cn } from "./lib/utils.js";
export function App() {
const { encounter, isEmpty } = useEncounterContext();
const sidePanel = useSidePanelContext();
const rolls = useInitiativeRollsContext();
useAutoStatBlock();
const [settingsOpen, setSettingsOpen] = useState(false);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to active combatant when turn changes
const activeIndex = encounter.activeIndex;
useEffect(() => {
if (activeIndex >= 0) {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [activeIndex]);
return (
<div className="flex h-dvh flex-col">
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
{!!actionBarAnim.showTopBar && (
<div
className={cn(
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
actionBarAnim.topBarClass,
)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation />
</div>
)}
{isEmpty ? (
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div
className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
inputRef={actionBarInputRef}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
autoFocus
/>
</div>
</div>
) : (
<>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
/>
))}
</div>
</div>
<div
className={cn(
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
actionBarAnim.settlingClass,
)}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
inputRef={actionBarInputRef}
onManagePlayers={() =>
playerCharacterRef.current?.openManagement()
}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
</>
)}
</div>
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel panelRole="pinned" side="left" />
)}
<StatBlockPanel panelRole="browse" side="right" />
<BulkImportToasts />
{rolls.rollSkippedCount > 0 && (
<Toast
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={rolls.dismissRollSkipped}
autoDismissMs={4000}
/>
)}
{!!rolls.rollSingleSkipped && (
<Toast
message="Can't roll — bestiary source not loaded"
onDismiss={rolls.dismissRollSingleSkipped}
autoDismissMs={4000}
/>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
<PlayerCharacterSection ref={playerCharacterRef} />
</div>
);
}