Key Libraries
zmNinjaNg leans on a small number of third-party libraries, and each one is
here because it solves a problem the platform does not. What follows is what
each library is used for in this codebase and where it bites. One dependency,
@capacitor/preferences, is declared in app/package.json and imported
nowhere in app/src: storage goes through localStorage under Zustand’s
persist middleware, or through lib/security/secureStorage.ts when the
value is a credential.
UI and Visualization
react-grid-layout
The Dashboard’s drag-and-drop grid.
components/dashboard/DashboardLayout.tsx imports GridLayout and wraps it
once at module scope: const WrappedGridLayout = WidthProvider(GridLayout).
WidthProvider measures the container and feeds its width down, which the
grid needs because it positions items in pixels rather than CSS columns.
The reason it fits here is the shape of what it hands back. A layout is a plain
object: { i, x, y, w, h }, with optional minW and minH. That is the
WidgetLayout interface in stores/dashboard.ts, and it drops straight
into the persisted store (keyed by profile id, under
STORAGE_KEYS.dashboardStore) with no adapter and no reconstruction on load.
A dragged widget stays where the user put it across restarts because saving the
layout is just saving that object. Each widget keeps both a single layout
and a layouts map of one entry per breakpoint.
The gotcha is drag capture: a widget whose content is itself interactive will
fight the grid for the pointer, which is why DashboardWidget.tsx is
particular about where the drag handle lives.
The montage screen borrows react-grid-layout’s Layout type
(components/montage/hooks/useMontageGrid.ts, and MontageSavedLayout in
stores/settings.ts) without ever rendering a GridLayout. Montage tiles
are positioned by the montage code itself.
video.js
MP4 event playback, in components/events/Mp4EventPlayer.tsx, the only file
that imports the video.js runtime. (contexts/PipContext.tsx imports its
types only, with import type videojs from 'video.js'.) videojs-markers
draws event points on the seek bar; the marker configs are built in
lib/event/video-markers.ts and applied to the player there.
ZoneMinder streams vary in format (MJPEG, multiple MP4 profiles), and the
native <video> element handles those inconsistently across browsers.
video.js gives one API and a plugin surface over the differences.
Neither live monitor streams nor ZMS event playback go through video.js. ZMS
delivers a multipart JPEG stream, not a container format a video element can
consume, so components/events/ZmsEventPlayer.tsx binds it to an <img>
element instead. components/monitors/LiveMonitorPlayer.tsx does the same for
the MJPEG path, and falls back to it when the WebRTC path (go2rtc, via
lib/vendor/go2rtc/video-rtc.js) is unavailable or fails. See
Go2RTC WebRTC Streaming.
lucide-react
The icon set. Icons are imported by name and sized with Tailwind classes rather
than props, and h-4 w-4 is the size almost every call site uses:
<RefreshCw className="h-4 w-4" />.
@radix-ui/*
Headless primitives behind the popovers, dialogs, dropdowns, and switches,
styled with Tailwind through the shadcn/ui pattern. Radix ships no visual
design at all, which leaves the look entirely to Tailwind while keeping
keyboard navigation and screen-reader semantics correct, and those are the part
that is expensive to get right by hand (I3).
recharts
Charting for dashboard widgets. components/dashboard/widgets/TimelineWidget.tsx
uses BarChart, Bar, XAxis, YAxis, Tooltip, and
ResponsiveContainer to plot event counts over time.
Data and Logic
date-fns
Date arithmetic and formatting. The functions actually imported across
app/src are format, parseISO, formatDistanceToNow, isToday,
isYesterday, and arithmetic helpers such as addHours, addDays,
subDays, startOfHour, startOfDay, startOfWeek, startOfMonth,
and differenceInDays.
Never call date-fns format() with a literal pattern for anything a user
sees. User-visible dates and times go through useDateTimeFormat() inside
React components, or formatAppDate / formatAppTime / formatAppDateTime
from lib/format-date-time.ts outside React. Those wrappers are the only
place that calls date-fns format(), and they read the user’s chosen date and
time format from profile settings before doing so. This is the Date and time
contract in AGENTS.project.md, and it covers canvas rendering, tooltips, and
scrubber overlays as much as it covers JSX.
Two things that look like date-fns but are not. Relative labels such as “5m ago”
come from Intl.RelativeTimeFormat in lib/relative-time.ts, because it
localizes into all five bundled languages without shipping locale files.
Converting ZoneMinder’s server-local timestamps for display uses
Intl.DateTimeFormat with an explicit timeZone in lib/time.ts. The
separate date-fns-tz package is used only by the assistant, where
toZonedTime and fromZonedTime resolve a spoken time range (“yesterday
evening”) into the server’s timezone before it becomes an API query
(lib/assistant/event-range.ts, window-interpreter.ts,
timeframe-stage.ts, system-prompt.ts).
react-hook-form & zod
Profile creation and the settings forms. Zod schemas describe the data shape and its validation rules, react-hook-form owns the field state and the render cycle.
Pairing them pays off because a Zod schema is two things at once: a runtime
check and, through z.infer, a TypeScript type. Form input and the API
payload are derived from the same declaration, so they cannot drift apart while
one side is edited.
That runtime half matters at the network boundary, where TypeScript has nothing
to offer. An interface is erased before the code runs, so nothing stops a
ZoneMinder server from returning a login response with no access_token and
nothing stops the app from reading it as a string. api/auth.ts therefore
runs LoginResponseSchema.parse(response.data) (the schema lives in
api/types.ts) and fails at the seam, with the received keys logged, instead
of far downstream where the symptom is an unexplained 401.
@tanstack/react-query
React Query holds every piece of data that came from a ZoneMinder server: the monitor list, event pages, server status, daemon health. The problem it solves is that this data is a cache of somebody else’s state, not this app’s state. It can go stale while the user is looking at it, two screens can want it at once, and a request can fail halfway. Putting it in a Zustand store would mean hand-writing loading flags, error flags, deduplication, and refetch timers for each screen.
Instead, each piece of server data is identified by a query key, and React
Query owns the cache entry behind that key: who is fetching it, when it last
succeeded, whether it needs refetching. Two components asking for the same key
share one network request and one cache entry. Every key in this codebase comes
from the queryKeys factory in lib/query/query-keys.ts rather than an
inline array, so that a mutation invalidating a key cannot drift out of sync
with the queries reading it (the Server queries contract in AGENTS.project.md).
The Events page’s endless list is the exception worth knowing about. It is not
useInfiniteQuery: hooks/useEventPagination.ts keeps a growing result
count in useState (mirrored into a store so it survives remounts) and
pages/Events.tsx re-runs a plain useQuery with
placeholderData: keepPreviousData, so the visible rows do not blank out
while the larger page is in flight.
staleTime and refetchInterval are the two options that get configured
most. Never hardcode the interval: read it from useBandwidthSettings() so
that low-bandwidth mode slows every poller at once (the Polling contract).
React Query’s model (queries, keys, the cache, and why a component re-renders when the cache entry changes) is taught in React Fundamentals. How this app wires it to ZoneMinder, including the query-key factory, invalidation, error walls, and the polling intervals, is in API and Data Fetching.
Mobile and Platform
@capacitor/*
Native device feature access for iOS and Android. The plugins imported by
app/src are:
Core (
@capacitor/core): platform detection (isNativePlatform).App (
@capacitor/app): theappStateChangeandpauselifecycle events. Used to flush the log buffer on background and to clear the notification badge on resume.Filesystem: writes the persistent log file (
lib/log-file/capacitor.ts) and exports logs from the Logs page.Network: detects network status changes on native platforms (WiFi/cellular transitions). Used by
useNotificationAutoConnectto trigger an immediate WebSocket reconnect when connectivity is restored.Haptics, Share, SplashScreen: as their names suggest.
Saved snapshots and videos do not go through Filesystem alone.
services/download.ts writes the file, then hands it to
@capacitor-community/media (Media.savePhoto / Media.saveVideo) so it
lands in the device gallery rather than in app-private storage.
Push notifications do not use @capacitor/push-notifications. They use
@capacitor-firebase/messaging, which handles APNS and FCM tokens through
one Firebase API. Badge counts use @capawesome/capacitor-badge.
Credentials are encrypted through lib/security/secureStorage.ts, which
delegates to @aparajita/capacitor-secure-storage on iOS and Android.
Biometric unlock uses @aparajita/capacitor-biometric-auth.
Capacitor exists here so iOS and Android ship from the same web codebase, with
a drop into a native plugin only where the web API has nothing to offer. Per
the Native contract in AGENTS.project.md, those plugins are loaded with
dynamic import() behind a platform check, never a static import, because a
static import of a native-only plugin breaks the web and Electron builds at
bundle time.
Internationalization
i18next & react-i18next
Translations reach components through const { t } = useTranslation(), and
the strings live in one JSON file per language under src/locales/. No
hardcoded user-facing strings, and all five languages (en, de, es, fr, zh) are
updated in the same commit; that is the Localization contract, and
Contributing to zmNinjaNg explains why a missing key does not look like a bug.
Constants
Named constants are first-party code, not a library, and the Constants contract
in AGENTS.project.md owns where they live: app-level values in
lib/zmninja-ng-constants.ts, ZoneMinder protocol values in
lib/zm/zm-constants.ts. The split is by who owns the value. ZoneMinder owns
what is in zm-constants.ts, so changing one of those is a protocol
violation rather than tuning.