diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml index fad8ac9b..b7d9d8ad 100644 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -104,8 +104,8 @@ body: - type: textarea id: logs attributes: - label: Full Motion log output (at log_level 8) - description: Please copy and paste the full log output. This will be automatically formatted into code, so no need for backticks. + label: Relevant Motion log output (at log_level 8) + description: Please copy and paste the log output. This will be automatically formatted into code, so no need for backticks. render: shell validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8c8a433..07cf4169 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,41 @@ concurrency: cancel-in-progress: true jobs: - build: + frontend: + name: Frontend Build & Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout source + uses: actions/checkout@main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Build frontend + run: npm run build + + - name: Verify build artifacts + run: | + test -f ../data/webui/index.html || { echo "index.html not found"; exit 1; } + test -d ../data/webui/assets || { echo "assets directory not found"; exit 1; } + echo "✅ Frontend build artifacts verified" + + backend: + name: Backend Build (${{ matrix.cxx }}, ${{ matrix.libc }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -50,7 +84,7 @@ jobs: if: matrix.libc == 'glibc' run: | sudo apt-get update - sudo apt-get install -y autopoint pkgconf gettext libcamera-dev libopencv-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswscale-dev libwebp-dev libmicrohttpd-dev libmariadb-dev libasound2-dev libpulse-dev libfftw3-dev + sudo apt-get install -y autoconf automake libtool autopoint autoconf-archive pkgconf gettext libcamera-dev libopencv-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswscale-dev libwebp-dev libmicrohttpd-dev libmariadb-dev libasound2-dev libpulse-dev libfftw3-dev - name: Set up Alpine if: matrix.libc == 'musl' @@ -62,14 +96,18 @@ jobs: clang file autoconf + autoconf-archive automake - gettext-dev libtool + bash + gettext-dev libzip-dev jpeg-dev v4l-utils-libs libcamera-dev opencv-dev + openexr-dev + imath-dev ffmpeg-dev libmicrohttpd-dev sqlite-dev @@ -84,7 +122,9 @@ jobs: - name: Configure build run: | autoreconf -fiv - ./configure CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + # Pass LIBS directly to configure for Alpine OpenEXR/Imath transitive dependency issue + # OpenCV's imgcodecs requires OpenEXR (Imf) and Imath but pkg-config doesn't pull them in + CONFIG_SHELL=/bin/bash ./configure CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} ${{ matrix.libc == 'musl' && 'LIBS="-lOpenEXR -lImath"' || '' }} - name: Build target run: | diff --git a/.gitignore b/.gitignore index 065df68b..c89c361e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ config.rpath config.status config.sub configure +configure~ autom4te.cache aclocal.m4 depcomp @@ -19,6 +20,25 @@ m4/ missing stamp-h1 +#devFiles +doc/analysis/ +doc/architecture/ +doc/github/ +doc/handoff-prompts/ +doc/installation/ +doc/issues/ +doc/libcamera/ +doc/plans/ +doc/project/ +doc/reviews/ +doc/scratchpads/ +doc/sub-agent-summaries/ +doc/summaries/ +doc/aar/ +doc/Screenshots/ +doc/sub-agent-plans/ +doc/MOTION_PI5_CHANGES.md + #data data/motion-dist.service data/motion-dist.conf @@ -26,6 +46,12 @@ data/camera1-dist.conf data/camera2-dist.conf data/camera3-dist.conf data/sound1-dist.conf +data/webcontrol/samplepage.html + +#development scripts (local only) +scripts/deploy-pi4.sh +scripts/deploy-pi5.sh +scripts/pi5-test.conf #src src/*.o @@ -59,3 +85,15 @@ po/stamp-po .vscode/ +# macOS +.DS_Store + +# Node.js / Testing +node_modules/ +package.json +package-lock.json +test-ui.mjs + +# Claude Code local settings +.claude/ +CLAUDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9485186..012bbba3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ Issues on the github site are intended to discuss code problems, crashes and application enhancements. The discussions is intended for questions after you have read the guide. + * User guide: [Motion User Guide](https://motion-project.github.io/motion_guide.html) + * User Group List: Please sign-up and send your issue to the list [Motion User](https://lists.sourceforge.net/lists/listinfo/motion-user) + * IRC: [#motion](ircs://irc.libera.chat:6697/motion) on Libera Chat + ## Questions submitted as issues Questions that are submitted as a github issue will be transferred to the discussions section. diff --git a/Makefile.am b/Makefile.am index 1ea3302a..c1c9bec5 100644 --- a/Makefile.am +++ b/Makefile.am @@ -23,6 +23,9 @@ SUBDIRS = src po pkgsysconfdir = $(sysconfdir)/@PACKAGE@ libstatedir = @localstatedir@/lib/@PACKAGE@ +libwebdir = @localstatedir@/lib/@PACKAGE@/webcontrol +webuiinstalldir = @localstatedir@/lib/@PACKAGE@/webui +scriptsdir = $(srcdir)/scripts dist_libstate_DATA = \ data/motion-dist.conf \ @@ -31,13 +34,19 @@ dist_libstate_DATA = \ data/camera3-dist.conf \ data/sound1-dist.conf +dist_libweb_DATA = \ + data/webcontrol/samplepage.html + dist_man_MANS = man/motion.1 dist_doc_DATA = \ doc/motion_guide.html \ doc/motion_stylesheet.css \ doc/motion_build.html \ - doc/motion_config.html + doc/motion_config.html \ + doc/motion_examples.html \ + doc/motion.gif \ + doc/motion.png ################################################################### ## Clean tilde crumbs and add prefix in config file. @@ -46,6 +55,73 @@ all-local: @rm -f po/*.po\~ @sed -e 's|$${prefix}|$(prefix)|' data/motion-dist.conf > data/motion-dist.conf.tmp && mv -f data/motion-dist.conf.tmp data/motion-dist.conf +################################################################### +## Install React webui files (handles dynamic asset filenames) +################################################################### +install-data-local: + @echo "Installing React webui..." + $(MKDIR_P) $(DESTDIR)$(webuiinstalldir)/assets + $(INSTALL_DATA) data/webui/index.html $(DESTDIR)$(webuiinstalldir)/ + $(INSTALL_DATA) data/webui/vite.svg $(DESTDIR)$(webuiinstalldir)/ 2>/dev/null || true + @for f in data/webui/assets/*; do \ + if [ -f "$$f" ]; then \ + $(INSTALL_DATA) "$$f" $(DESTDIR)$(webuiinstalldir)/assets/; \ + fi; \ + done + +uninstall-local: + rm -rf $(DESTDIR)$(webuiinstalldir) + +################################################################### +## Systemd service installation +################################################################### +SYSTEMD_UNIT_DIR = $(shell pkg-config --variable=systemdsystemunitdir systemd 2>/dev/null || echo "/etc/systemd/system") + +install-service: data/motion-dist.service + $(MKDIR_P) $(DESTDIR)$(SYSTEMD_UNIT_DIR) + @sed -e 's|$${exec_prefix}|$(exec_prefix)|g' \ + -e 's|$${prefix}|$(prefix)|g' \ + data/motion-dist.service > $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service.tmp + $(INSTALL_DATA) $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service.tmp $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service + @rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service.tmp + @echo "" + @echo "Service installed to $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service" + @if [ -z "$(DESTDIR)" ] && command -v systemctl >/dev/null 2>&1; then \ + echo "Reloading systemd and enabling service..."; \ + systemctl daemon-reload; \ + systemctl enable motion; \ + echo "Service enabled. Start with: sudo systemctl start motion"; \ + else \ + echo ""; \ + echo "To enable and start:"; \ + echo " sudo systemctl daemon-reload"; \ + echo " sudo systemctl enable motion"; \ + echo " sudo systemctl start motion"; \ + fi + @echo "" + +uninstall-service: + rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/motion.service + @echo "Service removed. Run 'sudo systemctl daemon-reload' to complete uninstall" + +setup-service: install-service + @echo "" + @echo "Running Motion service setup..." + @echo "" + @if [ ! -x $(scriptsdir)/setup-motion-service.sh ]; then \ + echo "Error: Setup script not found or not executable"; \ + echo "Expected: $(scriptsdir)/setup-motion-service.sh"; \ + exit 1; \ + fi + @if [ `id -u` -ne 0 ]; then \ + echo "Error: This target must be run with sudo"; \ + echo "Usage: sudo make setup-service"; \ + exit 1; \ + fi + @echo "Usage: $(scriptsdir)/setup-motion-service.sh --admin-pass PASSWORD --viewer-pass PASSWORD" + @echo "" + @echo "Run the setup script manually with your desired passwords." + ################################################################### ## Create pristine directories to match exactly distributed files ################################################################### @@ -60,6 +136,7 @@ cleanall: distclean @rm -f data/motion-dist.service data/motion-dist.conf @rm -f data/camera1-dist.conf data/camera2-dist.conf @rm -f data/camera3-dist.conf data/sound1-dist.conf + @rm -f data/webcontrol/samplepage.conf ################################################################### ## Testing options for maintainer diff --git a/README.md b/README.md index 748d8992..4ef09b92 100644 --- a/README.md +++ b/README.md @@ -3,33 +3,144 @@ Motion ## Description -Motion is a program that monitors the signal from video cameras -and detects changes in the images. Version 5.0 and later versions -remove some of the outdated processes, cleans up the code base and -introduces new functionality. - -The following are some of the things that are different from earlier -version of Motion (versions 4.7 and lower). -- Secondary detection method via OpenCV - - HOG (Histogram of Oriented Gradients) - - Haar cascade classifiers - - Deep neural networks(Caffe, TensorFlow, etc.) -- Direct Pi camera support and ability to change camera parameters -- Sound frequency detection -- Additional primary detection parameters -- Sound recording from network camera sources -- ROI pictures for output or secondary detection -- Enhanced web contorl - - Single port for all camera video streams and controls - - Consolidated stream(a single image) showing all cameras - - List/download movies - - Add/delete cameras - - Live view of the Motion log output - - Video streams via MPEGTS format - - Change/update configuration parameters - - Permits a user created web page - - JSON status/configuration pages - - POST web control processing +Motion is a program that monitors the signal from video cameras and detects changes in the images. + +## Requirements + +- Raspberry Pi 4 or newer (64-bit only) +- Raspberry Pi OS Bookworm or newer +- Camera Module v2/v3 or compatible USB camera + +## Quick Start + +### Installation (Interactive) + +SSH into your Pi and run: + +```bash +git clone https://github.com/Motion-Project/motion.git +cd motion +sudo scripts/install.sh +``` + +The installer will prompt for admin and viewer passwords, then start the service automatically. + +### Installation (Unattended) + +For automated deployments: + +```bash +git clone https://github.com/Motion-Project/motion.git +cd motion +sudo scripts/install.sh --unattended \ + --admin-pass "youradminpass" \ + --viewer-pass "yourviewerpass" +``` + +### One-Line Remote Install + +From your Mac/PC, install on a Pi via SSH: + +```bash +ssh admin@ 'git clone https://github.com/Motion-Project/motion.git && cd motion && sudo scripts/install.sh --unattended --admin-pass "adminpass" --viewer-pass "viewerpass"' +``` + +### Access the Web UI + +After installation: + +- **URL**: `http://:8080/` +- **Admin**: Full access (view, configure, control) +- **Viewer**: Read-only access (view only) + +### Useful Commands + +```bash +sudo journalctl -u motion -f # View logs +sudo systemctl restart motion # Restart service +sudo systemctl stop motion # Stop service +sudo motion-setup --reset # Reset passwords +``` + +## Authentication Setup + +Motion requires authentication to be configured before production use. + +### Quick Setup (CLI Tool) + +For custom passwords, use the setup tool: + +```bash +sudo motion-setup +``` + +Follow prompts to create admin and viewer accounts. Passwords will be hashed with bcrypt (work factor 12). + +### Password Reset + +If you forget your password: + +```bash +sudo motion-setup --reset +``` + +### Changing Passwords + +After logging in as admin: +1. Navigate to Settings > System +2. Enter new passwords +3. Click Save +4. Passwords will be hashed automatically + +### Manual Password Hash Generation + +If you need to generate a password hash manually: + +```bash +# Using Python +pip3 install bcrypt +python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt(12)).decode())" +``` + +Then edit `/usr/local/etc/motion/motion.conf`: + +```conf +webcontrol_authentication admin:$2b$12$abcdefghijk...xyz +``` + +Restart Motion: + +```bash +sudo systemctl restart motion +``` + +## Security + +- Admin username is fixed as "admin" (prevents enumeration) +- Passwords hashed with bcrypt (work factor 12) +- Sessions expire after 1 hour (configurable via `webcontrol_session_timeout`) +- Use HTTPS in production (reverse proxy recommended) + +## Configuration + +**Authentication Parameters**: + +```conf +# Admin account (full access) +webcontrol_authentication admin:$2b$12$hash... + +# Viewer account (read-only access) +webcontrol_user_authentication viewer:$2b$12$hash... + +# Session timeout (seconds, default 3600) +webcontrol_session_timeout 3600 +``` + +## Resources + +Please see the [Motion home page](https://motion-project.github.io/) for information regarding building the Motion code from source, documentation of the current and prior releases as well as recent news associated with the application. Review the [releases page](https://github.com/Motion-Project/motion/releases) for packaged deb files and release notes. The [issues page](https://github.com/Motion-Project/motion/issues) provides a method to report code bugs while the [discussions page](https://github.com/Motion-Project/motion/discussions) can be used for general questions. + +Additionally, there is [Motion User Group List](https://lists.sourceforge.net/lists/listinfo/motion-user) that you can sign up for and submit your question or the [IRC #motion](ircs://irc.libera.chat:6697/motion) on Libera Chat ## License diff --git a/configure.ac b/configure.ac index a85095e4..c3faf0d9 100644 --- a/configure.ac +++ b/configure.ac @@ -7,6 +7,7 @@ AC_CONFIG_HEADERS([config.hpp]) AC_CONFIG_SRCDIR([src/motion.cpp]) AC_CANONICAL_HOST AC_CONFIG_MACRO_DIR([m4]) +AC_LANG([C++]) AM_GNU_GETTEXT([external]) AM_GNU_GETTEXT_VERSION([0.19]) @@ -102,6 +103,56 @@ AS_IF([test "${ZLIB}" != "yes" ], [ ) CPPFLAGS="$HOLD_CPPFLAGS" +############################################################################## +### Check libxcrypt - Required. Needed for bcrypt password hashing +############################################################################## +AS_IF([pkgconf libxcrypt ], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS "`pkgconf --cflags libxcrypt` + TEMP_LIBS="$TEMP_LIBS "`pkgconf --libs libxcrypt` + ],[ + dnl libxcrypt not found via pkgconf, try system library + TEMP_LIBS="$TEMP_LIBS -lcrypt" + ] +) +HOLD_CPPFLAGS="$CPPFLAGS" +CPPFLAGS="$CPPFLAGS $TEMP_CPPFLAGS" +AC_CHECK_HEADERS(crypt.h,[XCRYPT="yes"],[XCRYPT="no"]) +AC_MSG_CHECKING(libxcrypt libraries) +AC_MSG_RESULT($XCRYPT) +AS_IF([test "${XCRYPT}" != "yes" ], [ + AC_MSG_ERROR([Required package libxcrypt not found, please check motion_guide.html and install necessary dependencies]) + ] +) +CPPFLAGS="$HOLD_CPPFLAGS" + +############################################################################## +### Check for systemd - Optional. Needed for watchdog integration +############################################################################## +AC_ARG_WITH([systemd], + AS_HELP_STRING([--with-systemd],[Build with systemd watchdog support]), + [SYSTEMD=$withval], + [SYSTEMD="yes"] +) +SYSTEMD_VER="" +AS_IF([test "${SYSTEMD}" = "no"], [ + AC_MSG_CHECKING(for systemd) + AC_MSG_RESULT(skipped) + ],[ + AC_MSG_CHECKING(for systemd) + AS_IF([pkgconf libsystemd], [ + SYSTEMD_VER="("`pkgconf --modversion libsystemd`")" + TEMP_CPPFLAGS="$TEMP_CPPFLAGS "`pkgconf --cflags libsystemd` + TEMP_LIBS="$TEMP_LIBS "`pkgconf --libs libsystemd` + AC_DEFINE([HAVE_SYSTEMD], [1], [Define to 1 if you have systemd support]) + SYSTEMD="yes" + ],[ + SYSTEMD="no" + ] + ) + AC_MSG_RESULT([$SYSTEMD]) + ] +) + ############################################################################## ### Check setting/getting thread names ############################################################################## @@ -133,32 +184,6 @@ AC_LINK_IFELSE( ] ) -############################################################################## -### Check XSI strerror_r. -############################################################################## -AC_MSG_CHECKING([for XSI strerror_r]) -HOLD_CPPFLAGS="$CPPFLAGS" -CPPFLAGS="$CPPFLAGS -Werror" -AC_LINK_IFELSE( - [AC_LANG_SOURCE([[ - #include - #include - int main(int argc, char** argv) { - char buf[1024]; - int ret = strerror_r(ENOMEM, buf, sizeof(buf)); - return ret; - }]]) - ],[ - AC_DEFINE([XSI_STRERROR_R], [1], [Define if you have XSI strerror_r function.]) - XSI_STRERROR="yes" - AC_MSG_RESULT([yes]) - ],[ - XSI_STRERROR="no" - AC_MSG_RESULT([no]) - ] -) -CPPFLAGS="$HOLD_CPPFLAGS" - ############################################################################### ### V4L2 Video System - Optional ############################################################################### @@ -648,6 +673,65 @@ AS_IF([test "${DEVELOPER_FLAGS}" = "yes"], [ ]) ]) +############################################################################## +### Security Hardening Flags +############################################################################## +AC_ARG_ENABLE([hardening], + AS_HELP_STRING([--disable-hardening], + [Disable compiler security hardening flags]), + [HARDENING=$enableval], + [HARDENING=yes]) + +AS_IF([test "${HARDENING}" = "yes"], [ + # Stack protection + AX_CHECK_COMPILE_FLAG([-fstack-protector-strong], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -fstack-protector-strong" + ], [ + AX_CHECK_COMPILE_FLAG([-fstack-protector], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -fstack-protector" + ]) + ]) + + # Stack clash protection (GCC 8+, Clang 11+) + AX_CHECK_COMPILE_FLAG([-fstack-clash-protection], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -fstack-clash-protection" + ]) + + # FORTIFY_SOURCE (requires optimization -O1 or higher) + # Check if optimization is enabled before adding FORTIFY_SOURCE + AS_IF([echo "$TEMP_CPPFLAGS" | grep -qE '\-O[[1-9]]'], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=2" + ], [ + AC_MSG_WARN([FORTIFY_SOURCE requires optimization (-O1 or higher), skipping]) + ]) + + # Position Independent Executable + AX_CHECK_COMPILE_FLAG([-fPIE], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -fPIE" + AX_CHECK_LINK_FLAG([-pie], [ + TEMP_LDFLAGS="$TEMP_LDFLAGS -pie" + ]) + ]) + + # Format string protection (treat as error) + AX_CHECK_COMPILE_FLAG([-Wformat -Werror=format-security], [ + TEMP_CPPFLAGS="$TEMP_CPPFLAGS -Wformat -Werror=format-security" + ]) + + # Linker hardening + AX_CHECK_LINK_FLAG([-Wl,-z,relro], [ + TEMP_LDFLAGS="$TEMP_LDFLAGS -Wl,-z,relro" + ]) + AX_CHECK_LINK_FLAG([-Wl,-z,now], [ + TEMP_LDFLAGS="$TEMP_LDFLAGS -Wl,-z,now" + ]) + AX_CHECK_LINK_FLAG([-Wl,-z,noexecstack], [ + TEMP_LDFLAGS="$TEMP_LDFLAGS -Wl,-z,noexecstack" + ]) + + AC_MSG_NOTICE([Security hardening flags enabled]) +]) + CPPFLAGS="$CPPFLAGS $TEMP_CPPFLAGS" LIBS="$LIBS $TEMP_LIBS" LDFLAGS="$TEMP_LDFLAGS" @@ -686,7 +770,7 @@ echo "OS : $host_os" echo "pthread_np : $PTHREAD_NP" echo "pthread_setname_np : $PTHREAD_SETNAME_NP" echo "pthread_getname_np : $PTHREAD_GETNAME_NP" -echo "XSI error : $XSI_STRERROR" +echo "systemd : $SYSTEMD$SYSTEMD_VER" echo "V4L2 : $V4L2" echo "webp : $WEBP$WEBP_VER" echo "libcamera : $LIBCAM$LIBCAM_VER" diff --git a/data/motion-dist.conf.in b/data/motion-dist.conf.in index 86a67f8c..64e97b5c 100644 --- a/data/motion-dist.conf.in +++ b/data/motion-dist.conf.in @@ -5,6 +5,10 @@ ; system working. There are many more options available. Please ; consult the documentation for the complete list of all options. ; +; SYNC REQUIREMENT: When changing default values in this file, also update: +; 1. src/conf.cpp - Compiled-in defaults (edit_generic_* functions) +; 2. frontend/src/utils/parameterMappings.ts - UI labels if showing defaults +; ;************************************************* ;***** System @@ -19,13 +23,15 @@ log_type ALL ;************************************************* device_name device_id -;target_dir +target_dir @localstatedir@/lib/@PACKAGE_NAME@/media ;************************************************* ;***** Source ;************************************************* ;v4l2_device /dev/video0 ;netcam_url +;libcam_brightness 0.0 +;libcam_contrast 1.0 ;************************************************* ;***** Image @@ -70,29 +76,67 @@ on_event_end ;************************************************* picture_output off picture_filename %v-%Y%m%d%H%M%S-%q +; Maximum pictures per motion event (0 = unlimited) +; Prevents runaway capture when picture_output=on +picture_max_per_event 0 +; Minimum milliseconds between picture captures (0 = no limit) +; Set to 1000 for max 1 picture/second, 500 for 2 pictures/second, etc. +picture_min_interval 0 ;************************************************* ;***** Movie ;************************************************* -movie_output on +movie_output off movie_max_time 120 -movie_quality 45 -movie_container mkv -movie_filename %v-%Y%m%d%H%M%S +movie_quality 60 +; Encoder preset controls CPU usage vs compression efficiency for H.264/H.265 +; Options: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow +; Faster presets = lower CPU usage but larger files. +; Note: ultrafast forces H.264 baseline profile (~30% larger files than superfast with high profile) +; For Pi 5: use ultrafast if CPU/heat is critical, superfast for better compression +movie_encoder_preset veryfast +movie_container mp4 +movie_filename %v-%{movienbr}-%Y%m%d%H%M%S ;************************************************* ;***** Web Control +;***** +;***** SECURITY: Configure authentication for network access: +;***** - Authentication method set to digest (more secure than basic) +;***** - Lockout protection enabled after 5 failed attempts +;***** - webcontrol_parms 3 allows admin access to all settings including credentials +;***** +;***** For localhost-only access, set webcontrol_localhost on +;***** For network access, configure webcontrol_authentication. ;************************************************* webcontrol_port 8080 webcontrol_localhost off -webcontrol_parms 2 +webcontrol_parms 3 +webcontrol_auth_method digest +webcontrol_lock_attempts 5 +webcontrol_lock_minutes 10 + +;***** React UI path (set to enable modern web interface) +;***** Leave empty or comment out to use legacy HTML interface +webcontrol_html_path @localstatedir@/lib/@PACKAGE_NAME@/webui ;************************************************* ;***** Web Stream ;************************************************* -stream_preview_scale 25 +stream_preview_scale 100 stream_preview_method combined +;************************************************* +;***** Database +;************************************************* +database_type sqlite3 +database_dbname @localstatedir@/lib/@PACKAGE_NAME@/motion.db +database_host localhost +database_port 0 +database_user +database_password +database_busy_timeout 0 + ;************************************************* ; Device config files - One for each device. ;************************************************* diff --git a/data/motion-dist.service.in b/data/motion-dist.service.in index 37ca9ec4..9aa5516a 100644 --- a/data/motion-dist.service.in +++ b/data/motion-dist.service.in @@ -1,7 +1,7 @@ # # This file is part of Motion. # -# MotionPLus is free software: you can redistribute it and/or modify +# Motion is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. @@ -17,24 +17,25 @@ # [Unit] -Description=Motion - Enhanced security camera monitoring software. +Description=Motion - Video motion detection and surveillance Documentation=man:motion(1) -After=local-fs.target network.target +After=local-fs.target network-online.target +Wants=network-online.target [Service] -User=motion -UMask=002 -ExecStart=@BIN_PATH@/motion - -Type=simple -# Set StandardError=journal to use journald to log messages from motion. -# See also the "log_file" config file option in motion(1) and systemd.service(5). -StandardError=null +Type=notify +NotifyAccess=main +WatchdogSec=30 +ExecStart=@bindir@/motion -n ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5 -ExecStartPre=!/bin/mkdir -p /var/log/motion -ExecStartPre=!/bin/chown motion:adm /var/log/motion + +# Camera access - run as root or add user to video group +# Can be overridden with "systemctl edit motion" +# Uncomment and set appropriate user/group: +#User=motion +#Group=video # Don't restart if unconfigured / misconfigured e.g. daemon disabled # in defaults file. See also /usr/include/sysexits.h or sysexits(3) @@ -47,24 +48,13 @@ RestartPreventExitStatus=78 #StartLimitAction= #FailureAction= -# The following can be used to increase the security of the -# installation, by mitigating risk from attacks on motion and the -# binaries, libraries and scripts which it relies on. They are disabled -# by default in case they break existing installations, e.g. those which -# call site-local scripts which would inherit the same restrictions. -# -# See systemd.exec(5) and -# http://0pointer.net/public/systemd-nluug-2014.pdf for more details -# of these and other related options. -# -# Use "systemctl edit motion" to change these settings. +# Security hardening (optional, may break some setups) +# Use "systemctl edit motion" to enable these settings. +# See systemd.exec(5) for details. #PrivateTmp=true -#NoNewPrivileges=yes -#PrivateNetwork=yes -#ProtectHome=yes -#DeviceAllow=/dev/video0 -#MountFlags=slave -#SystemCallFilter= +#ProtectHome=read-only +#ProtectSystem=strict +#ReadWritePaths=@localstatedir@/lib/@PACKAGE@ [Install] WantedBy=multi-user.target diff --git a/data/webui/assets/Dashboard-C1886l1P.js b/data/webui/assets/Dashboard-C1886l1P.js new file mode 100644 index 00000000..01db6c14 --- /dev/null +++ b/data/webui/assets/Dashboard-C1886l1P.js @@ -0,0 +1,3 @@ +import{r as a,j as e,u as W,a as O,t as Y,b as q,c as Q,d as G,e as H,f as z,g as V}from"./index-DEo73YRp.js";import{u as U,p as K,a as $,C as J,F as y,b as M,c as E,A as X,d as Z,e as F}from"./parameterMappings-Bp9bMVsO.js";function ee({isOpen:n,onClose:o,sheetRef:l,closeThreshold:c=.3}){const[d,m]=a.useState(!1),[g,b]=a.useState(0),v=a.useRef(0),h=a.useRef(0),r=a.useRef(0),i=a.useRef(0),j=a.useRef(0),x=a.useCallback(u=>{m(!0),v.current=u,h.current=u,j.current=u,i.current=Date.now(),r.current=0},[]),p=a.useCallback(u=>{if(!d)return;const _=Date.now(),s=_-i.current,f=u-j.current;s>0&&(r.current=f/s),i.current=_,j.current=u,h.current=u;const k=Math.max(0,u-v.current);b(k)},[d]),N=a.useCallback(()=>{if(!d)return;m(!1);const u=l.current?.offsetHeight||400;(g>u*c||r.current>.5)&&o(),b(0),r.current=0},[d,g,o,c,l]),w=a.useCallback(u=>{const _=u.touches[0],s=l.current?.getBoundingClientRect().top||0;_.clientY-s<=60&&x(_.clientY)},[x,l]),T=a.useCallback(u=>{d&&(u.preventDefault(),p(u.touches[0].clientY))},[d,p]),t=a.useCallback(()=>{N()},[N]),C=a.useCallback(u=>{const _=l.current?.getBoundingClientRect().top||0;if(u.clientY-_<=60){x(u.clientY);const f=P=>{p(P.clientY)},k=()=>{N(),document.removeEventListener("mousemove",f),document.removeEventListener("mouseup",k)};document.addEventListener("mousemove",f),document.addEventListener("mouseup",k)}},[x,p,N,l]),S=n?d?`translateY(${g}px)`:"translateY(0)":"translateY(100%)";return{handlers:{onTouchStart:w,onTouchMove:T,onTouchEnd:t,onMouseDown:C},style:{transform:S},state:{isDragging:d,dragOffset:g}}}function B({isOpen:n,onClose:o,title:l,children:c,headerRight:d}){const m=a.useRef(null),g=a.useRef(null),b=a.useRef(null),{handlers:v,style:h}=ee({isOpen:n,onClose:o,sheetRef:m});a.useEffect(()=>{if(!n)return;const i=x=>{x.key==="Escape"&&o()},j=x=>{if(x.key!=="Tab"||!m.current)return;const p=m.current.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),N=p[0],w=p[p.length-1];N&&(x.shiftKey?document.activeElement===N&&(x.preventDefault(),w?.focus()):document.activeElement===w&&(x.preventDefault(),N?.focus()))};return document.addEventListener("keydown",i),document.addEventListener("keydown",j),()=>{document.removeEventListener("keydown",i),document.removeEventListener("keydown",j)}},[n,o]),a.useEffect(()=>(n?document.body.style.overflow="hidden":document.body.style.overflow="",()=>{document.body.style.overflow=""}),[n]),a.useEffect(()=>{n?(b.current=document.activeElement,setTimeout(()=>{if(m.current){const i=m.current.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');i.length>0&&i[0].focus()}},100)):b.current&&b.current.focus()},[n]);const r=a.useCallback(i=>{i.target===i.currentTarget&&o()},[o]);return n?e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fixed inset-0 z-[100]",onClick:r,"aria-hidden":"true"}),e.jsxs("div",{ref:m,className:"fixed bottom-0 left-0 right-0 z-[101] bg-surface/95 backdrop-blur-sm rounded-t-2xl shadow-2xl transition-transform duration-300 ease-out border-t border-surface-elevated",style:{maxHeight:"45vh",transform:h.transform},role:"dialog","aria-modal":"true","aria-label":l,...v,children:[e.jsx("div",{className:"flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing touch-none",children:e.jsx("div",{className:"w-12 h-1.5 bg-gray-500 rounded-full"})}),e.jsxs("div",{className:"flex items-center justify-between px-4 pb-3 border-b border-surface-elevated",children:[e.jsx("h2",{className:"text-lg font-semibold",children:l}),e.jsxs("div",{className:"flex items-center gap-3",children:[d,e.jsx("button",{type:"button",onClick:o,className:"p-2 hover:bg-surface-elevated rounded-full transition-colors","aria-label":"Close",children:e.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M6 18L18 6M6 6l12 12"})})})]})]}),e.jsx("div",{ref:g,className:"overflow-y-auto overscroll-contain px-4 py-4",style:{maxHeight:"calc(45vh - 80px)"},children:c})]})]}):null}function L({title:n,defaultOpen:o=!0,children:l}){const[c,d]=a.useState(o);return e.jsxs("div",{className:"mb-4",children:[e.jsxs("button",{type:"button",className:"flex items-center justify-between w-full py-2 text-left",onClick:()=>d(!c),children:[e.jsx("span",{className:"font-medium text-sm",children:n}),e.jsx("svg",{className:`w-4 h-4 transition-transform ${c?"rotate-180":""}`,fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19 9l-7 7-7-7"})})]}),c&&e.jsx("div",{className:"pt-2",children:l})]})}function A({cameraId:n,config:o}){const{mutate:l,isPending:c}=W(),[d,m]=a.useState(null),[g,b]=a.useState({}),v=a.useRef({}),{data:h}=U(n);a.useEffect(()=>()=>{Object.values(v.current).forEach(clearTimeout)},[]),a.useEffect(()=>{b({})},[o]);const r=a.useCallback((t,C="")=>t in g?g[t]:o[t]?.value??C,[o,g]);a.useEffect(()=>{Object.values(v.current).forEach(clearTimeout),v.current={}},[n]);const i=a.useCallback((t,C)=>{b(u=>({...u,[t]:C})),v.current[t]&&clearTimeout(v.current[t]);const S=n;v.current[t]=setTimeout(()=>{l({camId:S,changes:{[t]:C}},{onSuccess:()=>{m(t),setTimeout(()=>m(null),1e3)}})},300)},[n,l]),j=a.useCallback((t,C)=>{b(S=>({...S,[t]:C})),l({camId:n,changes:{[t]:C}},{onSuccess:()=>{m(t),setTimeout(()=>m(null),1e3)}})},[n,l]),x=Number(r("width",640)),p=Number(r("height",480)),N=Number(r("threshold",1500)),w=K(N,x,p),T=a.useCallback(t=>{const C=$(t,x,p);i("threshold",C)},[x,p,i]);return e.jsxs("div",{className:"space-y-2",children:[e.jsx(J,{cameraId:n,readOnly:!0}),e.jsxs(L,{title:"Stream",defaultOpen:!1,children:[e.jsx(y,{label:"Quality",value:Number(r("stream_quality",50)),onChange:t=>i("stream_quality",t),min:1,max:100,unit:"%",helpText:"JPEG compression quality"}),e.jsx(y,{label:"Max Framerate",value:Number(r("stream_maxrate",15)),onChange:t=>i("stream_maxrate",t),min:1,max:30,unit:" fps",helpText:"Maximum stream framerate"})]}),e.jsxs(L,{title:"Image",defaultOpen:!1,children:[e.jsx(y,{label:"Brightness",value:Number(r("libcam_brightness",0)),onChange:t=>i("libcam_brightness",t),min:-1,max:1,step:.1,helpText:"Brightness adjustment"}),e.jsx(y,{label:"Contrast",value:Number(r("libcam_contrast",1)),onChange:t=>i("libcam_contrast",t),min:0,max:32,step:.5,helpText:"Contrast adjustment"}),e.jsx(y,{label:"Gain (ISO)",value:Number(r("libcam_gain",1)),onChange:t=>i("libcam_gain",t),min:0,max:10,step:.1,helpText:"Analog gain (0=auto, 1.0-10.0) (Gain 1.0 ~ ISO 100)"}),e.jsx(M,{label:"Auto White Balance",value:!!r("libcam_awb_enable",!0),onChange:t=>j("libcam_awb_enable",t),helpText:"Enable automatic white balance"}),!!r("libcam_awb_enable",!0)&&e.jsxs(e.Fragment,{children:[e.jsx(E,{label:"AWB Mode",value:String(r("libcam_awb_mode",0)),onChange:t=>j("libcam_awb_mode",Number(t)),options:X.map(t=>({value:String(t.value),label:t.label})),helpText:"White balance mode"}),h?.AwbLocked!==!1&&e.jsx(M,{label:"Lock AWB",value:!!r("libcam_awb_locked",!1),onChange:t=>j("libcam_awb_locked",t),helpText:"Lock white balance settings"})]}),!r("libcam_awb_enable",!0)&&e.jsxs(e.Fragment,{children:[h?.ColourTemperature!==!1&&e.jsx(y,{label:"Color Temperature",value:Number(r("libcam_colour_temp",0)),onChange:t=>i("libcam_colour_temp",t),min:0,max:1e4,step:100,unit:" K",helpText:"Manual color temperature in Kelvin (0-10000)"}),e.jsx(y,{label:"Red Gain",value:Number(r("libcam_colour_gain_r",1)),onChange:t=>i("libcam_colour_gain_r",t),min:0,max:8,step:.1,helpText:"Red color gain (0.0-8.0)"}),e.jsx(y,{label:"Blue Gain",value:Number(r("libcam_colour_gain_b",1)),onChange:t=>i("libcam_colour_gain_b",t),min:0,max:8,step:.1,helpText:"Blue color gain (0.0-8.0)"}),h?.ColourTemperature===!1&&e.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded",children:[e.jsx("strong",{children:"Note:"})," Color Temperature control is not available on this camera. NoIR cameras and some other sensors don't support this feature. Use Red/Blue Gain for manual white balance."]})]}),h?.AfMode&&e.jsxs(e.Fragment,{children:[e.jsx(E,{label:"Autofocus Mode",value:String(r("libcam_af_mode",0)),onChange:t=>j("libcam_af_mode",Number(t)),options:Z.map(t=>({value:String(t.value),label:t.label})),helpText:"Focus control mode"}),Number(r("libcam_af_mode",0))===0&&h?.LensPosition&&e.jsx(y,{label:"Lens Position",value:Number(r("libcam_lens_position",0)),onChange:t=>i("libcam_lens_position",t),min:0,max:15,step:.5,unit:" dioptres",helpText:"Manual focus position (0.0-15.0 dioptres)"})]}),!h?.AfMode&&h?.LensPosition&&e.jsx(y,{label:"Lens Position",value:Number(r("libcam_lens_position",0)),onChange:t=>i("libcam_lens_position",t),min:0,max:15,step:.5,unit:" dioptres",helpText:"Manual focus position (0.0-15.0 dioptres)"})]}),e.jsxs(L,{title:"Detection",defaultOpen:!1,children:[e.jsx(y,{label:"Threshold",value:w,onChange:T,min:0,max:20,step:.1,unit:"%",helpText:"Motion sensitivity (higher = less sensitive)"}),e.jsx(y,{label:"Noise Level",value:Number(r("noise_level",32)),onChange:t=>i("noise_level",t),min:1,max:255,helpText:"Noise tolerance"}),e.jsx(M,{label:"Auto-tune Noise",value:!!r("noise_tune",!1),onChange:t=>j("noise_tune",t),helpText:"Automatically adjust noise level"})]}),c&&e.jsx("div",{className:"text-center text-sm text-gray-400 py-2",children:"Applying..."}),d&&!c&&e.jsx("div",{className:"text-center text-sm text-green-400 py-2",children:"Applied!"})]})}function R({cameraId:n}){const[o,l]=a.useState(!1),c=d=>{d.stopPropagation();const m=document.querySelector(`[data-camera-id="${n}"]`);m&&(o?document.exitFullscreen&&document.exitFullscreen():m.requestFullscreen&&m.requestFullscreen())};return a.useEffect(()=>{const d=()=>{l(!!document.fullscreenElement)};return document.addEventListener("fullscreenchange",d),()=>{document.removeEventListener("fullscreenchange",d)}},[]),e.jsx("button",{type:"button",onClick:c,className:"p-1.5 hover:bg-surface rounded-full transition-colors","aria-label":"Toggle fullscreen",title:"Toggle fullscreen",children:e.jsx("svg",{className:"w-5 h-5 text-gray-400 hover:text-gray-200",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:o?e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"}):e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"})})})}function D({cameraId:n,onClick:o}){return e.jsx("button",{type:"button",onClick:l=>{l.stopPropagation(),o(n)},className:"p-1.5 hover:bg-surface rounded-full transition-colors","aria-label":"Quick settings",title:"Quick settings",children:e.jsxs("svg",{className:"w-5 h-5 text-gray-400 hover:text-gray-200",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:[e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"}),e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 12a3 3 0 11-6 0 3 3 0 016 0z"})]})})}function I({cameraId:n}){const[o,l]=a.useState(!1),{addToast:c}=O(),d=async m=>{if(m.stopPropagation(),!o){l(!0);try{await Y(n),c("Snapshot captured","success")}catch(g){c(g instanceof Error?g.message:"Failed to capture snapshot","error")}finally{l(!1)}}};return e.jsx("button",{type:"button",onClick:d,disabled:o,className:"p-1.5 hover:bg-surface rounded-full transition-colors disabled:opacity-50","aria-label":"Take snapshot",title:"Take snapshot",children:o?e.jsxs("svg",{className:"w-5 h-5 text-gray-400 animate-spin",fill:"none",viewBox:"0 0 24 24",children:[e.jsx("circle",{className:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor",strokeWidth:"4"}),e.jsx("path",{className:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"})]}):e.jsxs("svg",{className:"w-5 h-5 text-gray-400 hover:text-gray-200",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:[e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"}),e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 13a3 3 0 11-6 0 3 3 0 016 0z"})]})})}function te({cameras:n,selectedId:o,onSelect:l}){return n.length<=1?null:e.jsx("select",{value:o??"",onChange:c=>l(Number(c.target.value)),className:`px-3 py-1.5 bg-surface-elevated border border-gray-700 + rounded-lg text-sm focus:border-primary focus:ring-1 + focus:ring-primary cursor-pointer`,"aria-label":"Select camera",children:n.map(c=>e.jsx("option",{value:c.id,children:c.name},c.id))})}function ne(){const{data:n,isLoading:o,error:l}=q(),{role:c,isAuthenticated:d,authRequired:m}=Q(),{data:g}=G({enabled:!m||d}),[b,v]=a.useState(!1),[h,r]=a.useState(null),[i,j]=a.useState({}),x=s=>g?.find(f=>f.id===s)?.fps??0,p=s=>i[s]??0,N=s=>f=>{j(k=>({...k,[s]:f}))},{data:w}=H({queryKey:["config"],queryFn:async()=>{const s=await z("/0/api/config");return s.csrf_token&&V(s.csrf_token),s},enabled:b,staleTime:3e4}),T=s=>{r(s),v(!0)},t=()=>{v(!1)},C=n&&n.length>1?e.jsx(te,{cameras:n,selectedId:h,onSelect:r}):null,S=a.useMemo(()=>{if(!w||!h)return{};const s=w.configuration?.default||{},f=w.configuration?.[`cam${h}`]||{};return{...s,...f}},[w,h]);if(o)return e.jsxs("div",{className:"p-4 sm:p-6",children:[e.jsx("h2",{className:"text-2xl sm:text-3xl font-bold mb-4 sm:mb-6",children:"Camera Dashboard"}),e.jsx("div",{className:"flex flex-col items-center gap-6",children:[1].map(s=>e.jsxs("div",{className:"bg-surface-elevated rounded-lg p-4 animate-pulse w-full max-w-4xl",children:[e.jsx("div",{className:"h-6 bg-surface rounded w-1/3 mb-4"}),e.jsx("div",{className:"aspect-video bg-surface rounded"})]},s))})]});if(l)return e.jsxs("div",{className:"p-4 sm:p-6",children:[e.jsx("h2",{className:"text-2xl sm:text-3xl font-bold mb-4 sm:mb-6",children:"Camera Dashboard"}),e.jsxs("div",{className:"bg-danger/10 border border-danger rounded-lg p-4 max-w-2xl mx-auto",children:[e.jsxs("p",{className:"text-danger",children:["Failed to load cameras: ",l instanceof Error?l.message:"Unknown error"]}),e.jsx("button",{className:"mt-2 text-sm text-primary hover:underline",onClick:()=>window.location.reload(),children:"Retry"})]})]});if(!n||n.length===0)return e.jsxs("div",{className:"p-4 sm:p-6",children:[e.jsx("h2",{className:"text-2xl sm:text-3xl font-bold mb-4 sm:mb-6",children:"Camera Dashboard"}),e.jsxs("div",{className:"bg-surface-elevated rounded-lg p-8 text-center max-w-2xl mx-auto",children:[e.jsx("svg",{className:"w-16 h-16 mx-auto text-gray-600 mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1.5,d:"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"})}),e.jsx("p",{className:"text-gray-400 text-lg",children:"No cameras configured"}),e.jsx("p",{className:"text-sm text-gray-500 mt-2",children:"Add cameras in Motion's configuration file"})]})]});const u=n.length;if(u===1){const s=n[0],f=x(s.id),k=p(s.id);return e.jsxs("div",{className:"p-4 sm:p-6",children:[e.jsx("div",{className:"max-w-5xl mx-auto",children:e.jsxs("div",{className:"bg-surface-elevated rounded-lg overflow-hidden shadow-lg","data-camera-id":s.id,children:[e.jsxs("div",{className:"px-4 py-3 border-b border-surface flex items-center justify-between",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:"w-2 h-2 bg-green-500 rounded-full animate-pulse"}),e.jsx("h3",{className:"font-medium",children:s.name})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[s.width&&s.height&&e.jsxs("span",{className:"text-xs text-gray-500",children:[s.width,"x",s.height]}),(k>0||f>0)&&e.jsxs("span",{className:"text-xs font-mono text-gray-400 cursor-help",title:"streaming / capture frame rate",children:[k," / ",f," fps"]}),c==="admin"&&e.jsx(I,{cameraId:s.id}),e.jsx(R,{cameraId:s.id}),c==="admin"&&e.jsx(D,{cameraId:s.id,onClick:T})]})]}),e.jsx(F,{cameraId:s.id,onStreamFpsChange:N(s.id)})]})}),e.jsx(B,{isOpen:b,onClose:t,title:"Quick Settings",headerRight:C,children:h&&e.jsx(A,{cameraId:h,config:S})})]})}const _=()=>u===2?"grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6":u<=4?"grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6":"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6";return e.jsxs("div",{className:"p-4 sm:p-6",children:[e.jsxs("h2",{className:"text-2xl sm:text-3xl font-bold mb-4 sm:mb-6",children:["Cameras (",u,")"]}),e.jsx("div",{className:_(),children:n.map(s=>{const f=x(s.id),k=p(s.id);return e.jsxs("div",{className:"bg-surface-elevated rounded-lg overflow-hidden shadow-lg","data-camera-id":s.id,children:[e.jsxs("div",{className:"px-4 py-3 border-b border-surface flex items-center justify-between",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:"w-2 h-2 bg-green-500 rounded-full animate-pulse"}),e.jsx("h3",{className:"font-medium text-sm sm:text-base",children:s.name})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[s.width&&s.height&&e.jsxs("span",{className:"text-xs text-gray-500",children:[s.width,"x",s.height]}),(k>0||f>0)&&e.jsxs("span",{className:"text-xs font-mono text-gray-400 cursor-help",title:"streaming / capture frame rate",children:[k," / ",f," fps"]}),c==="admin"&&e.jsx(I,{cameraId:s.id}),e.jsx(R,{cameraId:s.id}),c==="admin"&&e.jsx(D,{cameraId:s.id,onClick:T})]})]}),e.jsx(F,{cameraId:s.id,onStreamFpsChange:N(s.id)})]},s.id)})}),e.jsx(B,{isOpen:b,onClose:t,title:"Quick Settings",headerRight:C,children:h&&e.jsx(A,{cameraId:h,config:S})})]})}export{ne as Dashboard}; diff --git a/data/webui/assets/Media-Cj2hjH__.js b/data/webui/assets/Media-Cj2hjH__.js new file mode 100644 index 00000000..aaf18935 --- /dev/null +++ b/data/webui/assets/Media-Cj2hjH__.js @@ -0,0 +1 @@ +import{j as e,a as ie,c as ne,p as re,r as n,y as R,b as oe,z as ce,A as de,B as ue,C as me,D as xe,E as pe,s as he}from"./index-DEo73YRp.js";function O({offset:a,limit:c,total:r,onPageChange:f,context:l}){const v=Math.floor(a/c)+1,i=Math.ceil(r/c),b=r===0?0:a+1,t=Math.min(a+c,r),x=a>0,d=a+c1&&e.jsxs("div",{className:"flex gap-2 items-center",children:[e.jsx("button",{onClick:()=>f(Math.max(0,a-c)),disabled:!x,className:"px-3 py-1 bg-surface-elevated hover:bg-surface rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm transition-colors","aria-label":"Previous page",children:"◀ Previous"}),e.jsxs("span",{className:"px-3 py-1 text-sm text-gray-400",children:["Page ",v," of ",i]}),e.jsx("button",{onClick:()=>f(a+c),disabled:!d,className:"px-3 py-1 bg-surface-elevated hover:bg-surface rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm transition-colors","aria-label":"Next page",children:"Next ▶"})]})]})}function k(a){const c=he();if(!c)return a;const r=a.includes("?")?"&":"?";return`${a}${r}token=${encodeURIComponent(c)}`}const g=100;function fe(a,c){if(!a||a.length!==8)return null;const r=parseInt(a.substring(0,4),10),f=parseInt(a.substring(4,6),10)-1,l=parseInt(a.substring(6,8),10);if(isNaN(r)||isNaN(f)||isNaN(l))return null;let v=0,i=0,b=0;if(c){const x=c.split(":");x.length>=2&&(v=parseInt(x[0],10)||0,i=parseInt(x[1],10)||0,b=parseInt(x[2],10)||0)}const t=new Date(r,f,l,v,i,b);return isNaN(t.getTime())?null:t}function be(){const{addToast:a}=ie(),{role:c}=ne(),r=re(),f=c==="admin",[l,v]=n.useState(1),[i,b]=n.useState("pictures"),[t,x]=n.useState("all"),[d,P]=n.useState(""),[Y,y]=n.useState(0),[o,w]=n.useState(null),[p,C]=n.useState(null),[j,A]=n.useState(null),N=Y*g;n.useEffect(()=>{y(0)},[l,i,d]),n.useEffect(()=>{t==="all"&&P("")},[t]),n.useEffect(()=>{t==="all"&&(r.invalidateQueries({queryKey:R.movies(l)}),r.invalidateQueries({queryKey:R.pictures(l)}))},[t,l,r]),n.useEffect(()=>{t==="folders"&&r.invalidateQueries({predicate:s=>Array.isArray(s.queryKey)&&s.queryKey[0]==="media-folders"&&s.queryKey[1]===l})},[t,d,l,r]);const{data:Z}=oe(),{data:E,isLoading:J}=ce(l,N,g,null,{enabled:t==="all"&&i==="pictures"}),{data:_,isLoading:X}=de(l,N,g,null,{enabled:t==="all"&&i==="movies"}),{data:u,isLoading:ee}=ue(l,d,N,g,{enabled:t==="folders"}),$=me(),L=xe(),F=pe(),T=t==="folders"?ee:i==="pictures"?J:X,K=t==="folders"?u?.files??[]:i==="pictures"?E?.pictures??[]:_?.movies??[],D=t==="folders"?u?.total_files??0:i==="pictures"?E?.total_count??0:_?.total_count??0,I=s=>s<1024?`${s} B`:s<1024*1024?`${(s/1024).toFixed(1)} KB`:s<1024*1024*1024?`${(s/(1024*1024)).toFixed(1)} MB`:`${(s/(1024*1024*1024)).toFixed(1)} GB`,V=(s,m)=>{const h=fe(s,m);return h?h.toLocaleDateString()+" "+h.toLocaleTimeString():"Unknown date"},se=n.useCallback((s,m)=>{m.stopPropagation(),C(s)},[]),te=n.useCallback(async()=>{if(p)try{const s="type"in p?p.type:i;s==="picture"||s==="pictures"?await $.mutateAsync({camId:l,pictureId:p.id}):await L.mutateAsync({camId:l,movieId:p.id}),a(`${s==="picture"||s==="pictures"?"Picture":"Movie"} deleted`,"success"),C(null),o?.id===p.id&&w(null)}catch{a("Failed to delete file","error")}},[p,i,l,$,L,a,o]),q=n.useCallback(()=>{C(null)},[]),W=n.useCallback((s,m)=>{A({path:s,fileCount:m})},[]),ae=n.useCallback(async()=>{if(j)try{const s=await F.mutateAsync({camId:l,path:j.path});a(`Deleted ${s.deleted.movies} movies, ${s.deleted.pictures} pictures`,"success"),A(null)}catch{a("Failed to delete folder contents","error")}},[j,l,F,a]),U=n.useCallback(()=>{A(null)},[]),z=n.useCallback(s=>{P(s),y(0)},[]),le=n.useCallback(()=>{u?.parent!==null&&(P(u?.parent??""),y(0))},[u]),S=$.isPending||L.isPending,B=F.isPending,G=d?d.split("/"):[];return e.jsxs("div",{className:"p-6",children:[e.jsx("div",{className:"flex items-center justify-between mb-6",children:e.jsx("h2",{className:"text-3xl font-bold",children:"Media"})}),e.jsxs("div",{className:"flex flex-wrap gap-4 mb-6",children:[e.jsxs("div",{children:[e.jsx("label",{htmlFor:"camera-select",className:"block text-sm font-medium mb-2",children:"Camera"}),e.jsx("select",{id:"camera-select",value:l,onChange:s=>v(parseInt(s.target.value)),className:"px-3 py-2 bg-surface border border-surface-elevated rounded-lg",children:Z?.map(s=>e.jsx("option",{value:s.id,children:s.name},s.id))})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium mb-2",children:"Type"}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx("button",{onClick:()=>b("pictures"),className:`px-4 py-2 rounded-lg transition-colors ${i==="pictures"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,disabled:t==="folders",children:"Pictures"}),e.jsx("button",{onClick:()=>b("movies"),className:`px-4 py-2 rounded-lg transition-colors ${i==="movies"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,disabled:t==="folders",children:"Movies"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium mb-2",children:"View"}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx("button",{onClick:()=>x("all"),className:`px-3 py-2 rounded-lg transition-colors text-sm ${t==="all"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"All"}),e.jsx("button",{onClick:()=>x("folders"),className:`px-3 py-2 rounded-lg transition-colors text-sm ${t==="folders"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"Folders"})]})]})]}),t==="folders"&&e.jsxs("div",{className:"mb-6 p-4 bg-surface-elevated rounded-lg",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-3",children:[e.jsx("svg",{className:"w-5 h-5 text-gray-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"})}),e.jsx("span",{className:"text-sm font-medium text-gray-300",children:"Browse Folders"})]}),e.jsxs("div",{className:"flex items-center gap-1 text-sm flex-wrap",children:[e.jsx("button",{onClick:()=>z(""),className:"hover:text-primary px-1",children:"Root"}),G.map((s,m)=>{const h=G.slice(0,m+1).join("/");return e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"text-gray-500",children:"/"}),e.jsx("button",{onClick:()=>z(h),className:"hover:text-primary px-1",children:s})]},m)})]}),u?.parent!==null&&e.jsxs("button",{onClick:le,className:"mt-2 flex items-center gap-2 text-sm text-gray-400 hover:text-white",children:[e.jsx("svg",{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 19l-7-7 7-7"})}),"Up to parent folder"]})]}),t==="folders"&&u&&u.folders.length>0&&e.jsx("div",{className:"mb-6 grid gap-2 md:grid-cols-2 lg:grid-cols-4",children:u.folders.map(s=>e.jsxs("div",{className:"bg-surface-elevated rounded-lg p-4 hover:ring-2 hover:ring-primary cursor-pointer transition-all group",children:[e.jsx("button",{onClick:()=>z(s.path),className:"w-full text-left",children:e.jsxs("div",{className:"flex items-start gap-3",children:[e.jsx("svg",{className:"w-8 h-8 text-yellow-500 flex-shrink-0",fill:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{d:"M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"})}),e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("p",{className:"font-medium truncate",children:s.name}),e.jsxs("p",{className:"text-xs text-gray-400",children:[s.file_count," files - ",I(s.total_size)]})]})]})}),f&&s.file_count>0&&e.jsx("button",{onClick:m=>{m.stopPropagation(),W(s.path,s.file_count)},className:"mt-2 w-full px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/40 rounded opacity-0 group-hover:opacity-100 transition-opacity",title:`Delete all ${s.file_count} media files in this folder`,children:"Delete All Media"})]},s.path))}),t==="folders"&&f&&d&&u&&u.total_files>0&&e.jsx("div",{className:"mb-4 flex justify-end",children:e.jsxs("button",{onClick:()=>W(d,u.total_files),className:"px-3 py-1.5 text-sm bg-red-600/20 text-red-400 hover:bg-red-600/40 rounded-lg flex items-center gap-2",children:[e.jsx("svg",{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})}),"Delete All Media in This Folder (",u.total_files,")"]})}),!T&&D>0&&e.jsx(O,{offset:N,limit:g,total:D,onPageChange:s=>y(s/g),context:t==="folders"&&d?`in ${d}`:void 0}),T?e.jsx("div",{className:"grid gap-4 md:grid-cols-3 lg:grid-cols-4",children:[1,2,3,4,5,6,7,8].map(s=>e.jsxs("div",{className:"bg-surface-elevated rounded-lg animate-pulse",children:[e.jsx("div",{className:"aspect-video bg-surface rounded-t-lg"}),e.jsxs("div",{className:"p-3",children:[e.jsx("div",{className:"h-4 bg-surface rounded w-3/4 mb-2"}),e.jsx("div",{className:"h-3 bg-surface rounded w-1/2"})]})]},s))}):K.length===0?e.jsxs("div",{className:"bg-surface-elevated rounded-lg p-8 text-center",children:[e.jsx("p",{className:"text-gray-400",children:t==="folders"?"No media files in this folder":`No ${i} found`}),e.jsx("p",{className:"text-sm text-gray-500 mt-2",children:t==="folders"?"Navigate to a folder with media files":i==="pictures"?"Motion detection snapshots will appear here":"Recorded videos will appear here"})]}):e.jsx("div",{className:"grid gap-4 md:grid-cols-3 lg:grid-cols-4",children:K.map(s=>{const m="type"in s?s.type:i==="pictures"?"picture":"movie",h=s.thumbnail||void 0;return e.jsxs("button",{className:"bg-surface-elevated rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary focus:ring-2 focus:ring-primary focus:outline-none transition-all group relative text-left w-full",onClick:()=>w(s),"aria-label":`View ${s.filename}`,children:[e.jsx("button",{onClick:M=>se(s,M),className:"absolute top-2 right-2 z-10 p-1.5 bg-red-600/80 hover:bg-red-600 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity",title:"Delete","aria-label":`Delete ${s.filename}`,children:e.jsx("svg",{className:"w-4 h-4 text-white",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})})}),e.jsxs("div",{className:"aspect-video bg-surface flex items-center justify-center relative overflow-hidden",children:[m==="picture"?e.jsx("img",{src:k(s.path),alt:s.filename,className:"w-full h-full object-cover",loading:"lazy"}):h?e.jsx("img",{src:k(h),alt:s.filename,className:"w-full h-full object-cover",loading:"lazy",onError:M=>{M.currentTarget.style.display="none";const H=M.currentTarget.parentElement;if(H){const Q=H.querySelector(".fallback-icon");Q&&(Q.style.display="flex")}}}):null,e.jsx("div",{className:`fallback-icon text-gray-400 absolute inset-0 flex items-center justify-center ${h?"hidden":""}`,children:e.jsxs("svg",{className:"w-16 h-16",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:[e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"}),e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})]})})]}),e.jsxs("div",{className:"p-3",children:[e.jsx("p",{className:"text-sm font-medium truncate",children:s.filename}),e.jsxs("div",{className:"flex justify-between text-xs text-gray-400 mt-1",children:[e.jsx("span",{children:I(s.size)}),e.jsx("span",{children:s.date?V(s.date,s.time):""})]})]})]},`${t}-${d}-${s.id}`)})}),!T&&D>0&&e.jsx(O,{offset:N,limit:g,total:D,onPageChange:s=>y(s/g),context:t==="folders"&&d?`in ${d}`:void 0}),o&&e.jsx("div",{className:"fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4",onClick:()=>w(null),children:e.jsxs("div",{className:"max-w-6xl w-full bg-surface-elevated rounded-lg overflow-hidden",onClick:s=>s.stopPropagation(),children:[e.jsxs("div",{className:"p-4 border-b border-surface flex justify-between items-center",children:[e.jsxs("div",{children:[e.jsx("h3",{className:"font-medium",children:o.filename}),e.jsxs("p",{className:"text-sm text-gray-400",children:[I(o.size)," ",o.date&&`- ${V(o.date,o.time)}`]})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx("a",{href:k(o.path),download:o.filename,className:"px-3 py-1 bg-primary hover:bg-primary-hover rounded text-sm",onClick:s=>s.stopPropagation(),children:"Download"}),e.jsx("button",{onClick:s=>{s.stopPropagation(),C(o)},className:"px-3 py-1 bg-red-600 hover:bg-red-700 rounded text-sm",children:"Delete"}),e.jsx("button",{onClick:()=>w(null),className:"px-3 py-1 bg-surface hover:bg-surface-elevated rounded text-sm",children:"Close"})]})]}),e.jsx("div",{className:"p-4",children:("type"in o?o.type:i)==="picture"?e.jsx("img",{src:k(o.path),alt:o.filename,className:"w-full h-auto max-h-[70vh] object-contain"}):e.jsx("video",{src:k(o.path),controls:!0,className:"w-full h-auto max-h-[70vh]",autoPlay:!0,children:"Your browser does not support video playback."})})]})}),p&&e.jsx("div",{className:"fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4",onClick:q,children:e.jsx("div",{className:"w-full max-w-md bg-surface-elevated rounded-lg",onClick:s=>s.stopPropagation(),children:e.jsxs("div",{className:"p-6",children:[e.jsx("h3",{className:"text-xl font-bold mb-2",children:"Delete File?"}),e.jsxs("p",{className:"text-gray-400 mb-4",children:["Are you sure you want to delete ",e.jsx("span",{className:"font-medium text-white",children:p.filename}),"? This action cannot be undone."]}),e.jsxs("div",{className:"flex gap-3 justify-end",children:[e.jsx("button",{onClick:q,disabled:S,className:"px-4 py-2 bg-surface hover:bg-surface-elevated rounded-lg disabled:opacity-50",children:"Cancel"}),e.jsx("button",{onClick:te,disabled:S,className:"px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50",children:S?"Deleting...":"Delete"})]})]})})}),j&&e.jsx("div",{className:"fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4",onClick:U,children:e.jsx("div",{className:"w-full max-w-md bg-surface-elevated rounded-lg",onClick:s=>s.stopPropagation(),children:e.jsxs("div",{className:"p-6",children:[e.jsx("h3",{className:"text-xl font-bold mb-2 text-red-400",children:"Delete All Media Files?"}),e.jsxs("p",{className:"text-gray-400 mb-4",children:["Are you sure you want to delete ",e.jsxs("span",{className:"font-medium text-white",children:["all ",j.fileCount," media files"]})," in folder ",e.jsx("span",{className:"font-mono text-primary",children:j.path||"root"}),"?"]}),e.jsx("p",{className:"text-sm text-yellow-500 mb-4",children:"This will delete movies, pictures, and their thumbnails. Subfolders will NOT be deleted. This action cannot be undone."}),e.jsxs("div",{className:"flex gap-3 justify-end",children:[e.jsx("button",{onClick:U,disabled:B,className:"px-4 py-2 bg-surface hover:bg-surface-elevated rounded-lg disabled:opacity-50",children:"Cancel"}),e.jsx("button",{onClick:ae,disabled:B,className:"px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50",children:B?"Deleting...":"Delete All"})]})]})})})]})}export{be as Media}; diff --git a/data/webui/assets/Settings-CrTNsWoa.js b/data/webui/assets/Settings-CrTNsWoa.js new file mode 100644 index 00000000..3d6f1cb8 --- /dev/null +++ b/data/webui/assets/Settings-CrTNsWoa.js @@ -0,0 +1,22 @@ +import{r as x,j as r,h as Pe,a as Ye,i as gr,k as xr,l as br,b as vr,m as yr,n as _r,o as Ft,e as qe,p as Zt,q as nt,s as jr,v as wr,f as Lt,c as Nr,u as Sr,w as Cr,g as kr}from"./index-DEo73YRp.js";import{c as R,b as Y,R as st,f as at,F as P,g as Tr,h as Pr,A as $r,d as zr,i as Mr,j as Or,m as ot,k as it,l as Ir,p as Ar,L as Dr,a as Er,n as Rr,o as Fr,q as Zr,r as re,s as Ie,E as Lr,t as Ur,M as ct,u as Hr,C as Vr,v as Br,w as Wr}from"./parameterMappings-Bp9bMVsO.js";function S({label:e,value:t,onChange:n,type:s="text",placeholder:a,disabled:o=!1,required:i=!1,helpText:c,error:l,min:u,max:d,step:m,originalValue:p,showVisibilityToggle:f}){const[y,g]=x.useState(!1),w=D=>{n(D.target.value)},_=!!l,v=p!==void 0&&String(t)!==String(p),$=s==="password",B=f??$,Z=$&&y?"text":s;return r.jsxs("div",{className:"mb-4",children:[r.jsxs("label",{className:"block text-sm font-medium mb-1",children:[e,i&&r.jsx("span",{className:"text-red-500 ml-1",children:"*"}),v&&r.jsx("span",{className:"ml-2 text-xs text-yellow-400",children:"(modified)"})]}),r.jsxs("div",{className:"relative",children:[r.jsx("input",{type:Z,value:t,onChange:w,placeholder:a,disabled:o,required:i,min:u,max:d,step:m,className:`w-full px-3 py-2 bg-surface border rounded-lg focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed ${B?"pr-10":""} ${_?"border-red-500 focus:ring-red-500":v?"border-yellow-500/50 focus:ring-yellow-500":"border-surface-elevated focus:ring-primary"}`,"aria-invalid":_,"aria-describedby":_?`${e}-error`:void 0}),B&&r.jsx("button",{type:"button",onClick:()=>g(!y),className:"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-200 transition-colors","aria-label":y?"Hide password":"Show password",children:y?r.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"})}):r.jsxs("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:[r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 12a3 3 0 11-6 0 3 3 0 016 0z"}),r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"})]})})]}),_&&r.jsx("p",{id:`${e}-error`,className:"mt-1 text-sm text-red-400",role:"alert",children:l}),c&&!_&&r.jsx("p",{className:"mt-1 text-sm text-gray-400",children:c})]})}function O({title:e,description:t,children:n,collapsible:s=!1,defaultOpen:a=!0}){const[o,i]=x.useState(a),c=()=>{s&&i(!o)};return r.jsxs("div",{className:"bg-surface-elevated rounded-lg p-6 mb-6",children:[r.jsxs("div",{className:`flex items-center justify-between ${s?"cursor-pointer":""}`,onClick:c,children:[r.jsxs("div",{children:[r.jsx("h3",{className:"text-lg font-semibold",children:e}),t&&r.jsx("p",{className:"text-sm text-gray-400 mt-1",children:t})]}),s&&r.jsx("svg",{className:`w-5 h-5 transition-transform ${o?"rotate-180":""}`,fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19 9l-7 7-7-7"})})]}),o&&r.jsx("div",{className:"mt-4",children:n})]})}function h(e,t,n){function s(c,l){if(c._zod||Object.defineProperty(c,"_zod",{value:{def:l,constr:i,traits:new Set},enumerable:!1}),c._zod.traits.has(e))return;c._zod.traits.add(e),t(c,l);const u=i.prototype,d=Object.keys(u);for(let m=0;mn?.Parent&&c instanceof n.Parent?!0:c?._zod?.traits?.has(e)}),Object.defineProperty(i,"name",{value:e}),i}class le extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}class Ut extends Error{constructor(t){super(`Encountered unidirectional transform during encode: ${t}`),this.name="ZodEncodeError"}}const Yr={};function se(e){return Yr}function Le(e,t){return typeof t=="bigint"?t.toString():t}function Je(e){return e==null}function Ge(e){const t=e.startsWith("^")?1:0,n=e.endsWith("$")?e.length-1:e.length;return e.slice(t,n)}function qr(e,t){const n=(e.toString().split(".")[1]||"").length,s=t.toString();let a=(s.split(".")[1]||"").length;if(a===0&&/\d?e-\d?/.test(s)){const l=s.match(/\d?e-(\d?)/);l?.[1]&&(a=Number.parseInt(l[1]))}const o=n>a?n:a,i=Number.parseInt(e.toFixed(o).replace(".","")),c=Number.parseInt(t.toFixed(o).replace(".",""));return i%c/10**o}const lt=Symbol("evaluating");function I(e,t,n){let s;Object.defineProperty(e,t,{get(){if(s!==lt)return s===void 0&&(s=lt,s=n()),s},set(a){Object.defineProperty(e,t,{value:a})},configurable:!0})}function Jr(...e){const t={};for(const n of e){const s=Object.getOwnPropertyDescriptors(n);Object.assign(t,s)}return Object.defineProperties({},t)}function Gr(e){return e.toLowerCase().trim().replace(/[^\w\s-]/g,"").replace(/[\s_-]+/g,"-").replace(/^-+|-+$/g,"")}const Ht="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function ut(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function Ue(e){if(ut(e)===!1)return!1;const t=e.constructor;if(t===void 0||typeof t!="function")return!0;const n=t.prototype;return!(ut(n)===!1||Object.prototype.hasOwnProperty.call(n,"isPrototypeOf")===!1)}function Vt(e){return Ue(e)?{...e}:Array.isArray(e)?[...e]:e}function Ke(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function Kr(e,t,n){const s=new e._zod.constr(t??e._zod.def);return(!t||n?.parent)&&(s._zod.parent=e),s}function j(e){const t=e;if(!t)return{};if(typeof t=="string")return{error:()=>t};if(t?.message!==void 0){if(t?.error!==void 0)throw new Error("Cannot specify both `message` and `error` params");t.error=t.message}return delete t.message,typeof t.error=="string"?{...t,error:()=>t.error}:t}const Qr={safeint:[Number.MIN_SAFE_INTEGER,Number.MAX_SAFE_INTEGER],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-34028234663852886e22,34028234663852886e22],float64:[-Number.MAX_VALUE,Number.MAX_VALUE]};function ce(e,t=0){if(e.aborted===!0)return!0;for(let n=t;n{var s;return(s=n).path??(s.path=[]),n.path.unshift(e),n})}function _e(e){return typeof e=="string"?e:e?.message}function ae(e,t,n){const s={...e,path:e.path??[]};if(!e.message){const a=_e(e.inst?._zod.def?.error?.(e))??_e(t?.error?.(e))??_e(n.customError?.(e))??_e(n.localeError?.(e))??"Invalid input";s.message=a}return delete s.inst,delete s.continue,t?.reportInput||delete s.input,s}function Qe(e){return Array.isArray(e)?"array":typeof e=="string"?"string":"unknown"}function xe(...e){const[t,n,s]=e;return typeof t=="string"?{message:t,code:"custom",input:n,inst:s}:{...t}}const Bt=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,Le,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},Wt=h("$ZodError",Bt),Yt=h("$ZodError",Bt,{Parent:Error});function en(e,t=n=>n.message){const n={},s=[];for(const a of e.issues)a.path.length>0?(n[a.path[0]]=n[a.path[0]]||[],n[a.path[0]].push(t(a))):s.push(t(a));return{formErrors:s,fieldErrors:n}}function tn(e,t=n=>n.message){const n={_errors:[]},s=a=>{for(const o of a.issues)if(o.code==="invalid_union"&&o.errors.length)o.errors.map(i=>s({issues:i}));else if(o.code==="invalid_key")s({issues:o.issues});else if(o.code==="invalid_element")s({issues:o.issues});else if(o.path.length===0)n._errors.push(t(o));else{let i=n,c=0;for(;c(t,n,s,a)=>{const o=s?Object.assign(s,{async:!1}):{async:!1},i=t._zod.run({value:n,issues:[]},o);if(i instanceof Promise)throw new le;if(i.issues.length){const c=new(a?.Err??e)(i.issues.map(l=>ae(l,o,se())));throw Ht(c,a?.callee),c}return i.value},et=e=>async(t,n,s,a)=>{const o=s?Object.assign(s,{async:!0}):{async:!0};let i=t._zod.run({value:n,issues:[]},o);if(i instanceof Promise&&(i=await i),i.issues.length){const c=new(a?.Err??e)(i.issues.map(l=>ae(l,o,se())));throw Ht(c,a?.callee),c}return i.value},$e=e=>(t,n,s)=>{const a=s?{...s,async:!1}:{async:!1},o=t._zod.run({value:n,issues:[]},a);if(o instanceof Promise)throw new le;return o.issues.length?{success:!1,error:new(e??Wt)(o.issues.map(i=>ae(i,a,se())))}:{success:!0,data:o.value}},rn=$e(Yt),ze=e=>async(t,n,s)=>{const a=s?Object.assign(s,{async:!0}):{async:!0};let o=t._zod.run({value:n,issues:[]},a);return o instanceof Promise&&(o=await o),o.issues.length?{success:!1,error:new e(o.issues.map(i=>ae(i,a,se())))}:{success:!0,data:o.value}},nn=ze(Yt),sn=e=>(t,n,s)=>{const a=s?Object.assign(s,{direction:"backward"}):{direction:"backward"};return Xe(e)(t,n,a)},an=e=>(t,n,s)=>Xe(e)(t,n,s),on=e=>async(t,n,s)=>{const a=s?Object.assign(s,{direction:"backward"}):{direction:"backward"};return et(e)(t,n,a)},cn=e=>async(t,n,s)=>et(e)(t,n,s),ln=e=>(t,n,s)=>{const a=s?Object.assign(s,{direction:"backward"}):{direction:"backward"};return $e(e)(t,n,a)},un=e=>(t,n,s)=>$e(e)(t,n,s),dn=e=>async(t,n,s)=>{const a=s?Object.assign(s,{direction:"backward"}):{direction:"backward"};return ze(e)(t,n,a)},mn=e=>async(t,n,s)=>ze(e)(t,n,s),hn=/^[cC][^\s-]{8,}$/,pn=/^[0-9a-z]+$/,fn=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/,gn=/^[0-9a-vA-V]{20}$/,xn=/^[A-Za-z0-9]{27}$/,bn=/^[a-zA-Z0-9_-]{21}$/,vn=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,yn=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/,dt=e=>e?new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${e}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`):/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/,_n=/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/,jn="^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$";function wn(){return new RegExp(jn,"u")}const Nn=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,Sn=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/,Cn=/^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/,kn=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,Tn=/^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/,qt=/^[A-Za-z0-9_-]*$/,Pn=/^\+(?:[0-9]){6,14}[0-9]$/,Jt="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",$n=new RegExp(`^${Jt}$`);function Gt(e){const t="(?:[01]\\d|2[0-3]):[0-5]\\d";return typeof e.precision=="number"?e.precision===-1?`${t}`:e.precision===0?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}function zn(e){return new RegExp(`^${Gt(e)}$`)}function Mn(e){const t=Gt({precision:e.precision}),n=["Z"];e.local&&n.push(""),e.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)");const s=`${t}(?:${n.join("|")})`;return new RegExp(`^${Jt}T(?:${s})$`)}const On=e=>{const t=e?`[\\s\\S]{${e?.minimum??0},${e?.maximum??""}}`:"[\\s\\S]*";return new RegExp(`^${t}$`)},In=/^-?\d+$/,An=/^-?\d+(?:\.\d+)?/,Dn=/^[^A-Z]*$/,En=/^[^a-z]*$/,q=h("$ZodCheck",(e,t)=>{var n;e._zod??(e._zod={}),e._zod.def=t,(n=e._zod).onattach??(n.onattach=[])}),Kt={number:"number",bigint:"bigint",object:"date"},Qt=h("$ZodCheckLessThan",(e,t)=>{q.init(e,t);const n=Kt[typeof t.value];e._zod.onattach.push(s=>{const a=s._zod.bag,o=(t.inclusive?a.maximum:a.exclusiveMaximum)??Number.POSITIVE_INFINITY;t.value{(t.inclusive?s.value<=t.value:s.value{q.init(e,t);const n=Kt[typeof t.value];e._zod.onattach.push(s=>{const a=s._zod.bag,o=(t.inclusive?a.minimum:a.exclusiveMinimum)??Number.NEGATIVE_INFINITY;t.value>o&&(t.inclusive?a.minimum=t.value:a.exclusiveMinimum=t.value)}),e._zod.check=s=>{(t.inclusive?s.value>=t.value:s.value>t.value)||s.issues.push({origin:n,code:"too_small",minimum:t.value,input:s.value,inclusive:t.inclusive,inst:e,continue:!t.abort})}}),Rn=h("$ZodCheckMultipleOf",(e,t)=>{q.init(e,t),e._zod.onattach.push(n=>{var s;(s=n._zod.bag).multipleOf??(s.multipleOf=t.value)}),e._zod.check=n=>{if(typeof n.value!=typeof t.value)throw new Error("Cannot mix number and bigint in multiple_of check.");(typeof n.value=="bigint"?n.value%t.value===BigInt(0):qr(n.value,t.value)===0)||n.issues.push({origin:typeof n.value,code:"not_multiple_of",divisor:t.value,input:n.value,inst:e,continue:!t.abort})}}),Fn=h("$ZodCheckNumberFormat",(e,t)=>{q.init(e,t),t.format=t.format||"float64";const n=t.format?.includes("int"),s=n?"int":"number",[a,o]=Qr[t.format];e._zod.onattach.push(i=>{const c=i._zod.bag;c.format=t.format,c.minimum=a,c.maximum=o,n&&(c.pattern=In)}),e._zod.check=i=>{const c=i.value;if(n){if(!Number.isInteger(c)){i.issues.push({expected:s,format:t.format,code:"invalid_type",continue:!1,input:c,inst:e});return}if(!Number.isSafeInteger(c)){c>0?i.issues.push({input:c,code:"too_big",maximum:Number.MAX_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:e,origin:s,continue:!t.abort}):i.issues.push({input:c,code:"too_small",minimum:Number.MIN_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:e,origin:s,continue:!t.abort});return}}co&&i.issues.push({origin:"number",input:c,code:"too_big",maximum:o,inst:e})}}),Zn=h("$ZodCheckMaxLength",(e,t)=>{var n;q.init(e,t),(n=e._zod.def).when??(n.when=s=>{const a=s.value;return!Je(a)&&a.length!==void 0}),e._zod.onattach.push(s=>{const a=s._zod.bag.maximum??Number.POSITIVE_INFINITY;t.maximum{const a=s.value;if(a.length<=t.maximum)return;const i=Qe(a);s.issues.push({origin:i,code:"too_big",maximum:t.maximum,inclusive:!0,input:a,inst:e,continue:!t.abort})}}),Ln=h("$ZodCheckMinLength",(e,t)=>{var n;q.init(e,t),(n=e._zod.def).when??(n.when=s=>{const a=s.value;return!Je(a)&&a.length!==void 0}),e._zod.onattach.push(s=>{const a=s._zod.bag.minimum??Number.NEGATIVE_INFINITY;t.minimum>a&&(s._zod.bag.minimum=t.minimum)}),e._zod.check=s=>{const a=s.value;if(a.length>=t.minimum)return;const i=Qe(a);s.issues.push({origin:i,code:"too_small",minimum:t.minimum,inclusive:!0,input:a,inst:e,continue:!t.abort})}}),Un=h("$ZodCheckLengthEquals",(e,t)=>{var n;q.init(e,t),(n=e._zod.def).when??(n.when=s=>{const a=s.value;return!Je(a)&&a.length!==void 0}),e._zod.onattach.push(s=>{const a=s._zod.bag;a.minimum=t.length,a.maximum=t.length,a.length=t.length}),e._zod.check=s=>{const a=s.value,o=a.length;if(o===t.length)return;const i=Qe(a),c=o>t.length;s.issues.push({origin:i,...c?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:s.value,inst:e,continue:!t.abort})}}),Me=h("$ZodCheckStringFormat",(e,t)=>{var n,s;q.init(e,t),e._zod.onattach.push(a=>{const o=a._zod.bag;o.format=t.format,t.pattern&&(o.patterns??(o.patterns=new Set),o.patterns.add(t.pattern))}),t.pattern?(n=e._zod).check??(n.check=a=>{t.pattern.lastIndex=0,!t.pattern.test(a.value)&&a.issues.push({origin:"string",code:"invalid_format",format:t.format,input:a.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(s=e._zod).check??(s.check=()=>{})}),Hn=h("$ZodCheckRegex",(e,t)=>{Me.init(e,t),e._zod.check=n=>{t.pattern.lastIndex=0,!t.pattern.test(n.value)&&n.issues.push({origin:"string",code:"invalid_format",format:"regex",input:n.value,pattern:t.pattern.toString(),inst:e,continue:!t.abort})}}),Vn=h("$ZodCheckLowerCase",(e,t)=>{t.pattern??(t.pattern=Dn),Me.init(e,t)}),Bn=h("$ZodCheckUpperCase",(e,t)=>{t.pattern??(t.pattern=En),Me.init(e,t)}),Wn=h("$ZodCheckIncludes",(e,t)=>{q.init(e,t);const n=Ke(t.includes),s=new RegExp(typeof t.position=="number"?`^.{${t.position}}${n}`:n);t.pattern=s,e._zod.onattach.push(a=>{const o=a._zod.bag;o.patterns??(o.patterns=new Set),o.patterns.add(s)}),e._zod.check=a=>{a.value.includes(t.includes,t.position)||a.issues.push({origin:"string",code:"invalid_format",format:"includes",includes:t.includes,input:a.value,inst:e,continue:!t.abort})}}),Yn=h("$ZodCheckStartsWith",(e,t)=>{q.init(e,t);const n=new RegExp(`^${Ke(t.prefix)}.*`);t.pattern??(t.pattern=n),e._zod.onattach.push(s=>{const a=s._zod.bag;a.patterns??(a.patterns=new Set),a.patterns.add(n)}),e._zod.check=s=>{s.value.startsWith(t.prefix)||s.issues.push({origin:"string",code:"invalid_format",format:"starts_with",prefix:t.prefix,input:s.value,inst:e,continue:!t.abort})}}),qn=h("$ZodCheckEndsWith",(e,t)=>{q.init(e,t);const n=new RegExp(`.*${Ke(t.suffix)}$`);t.pattern??(t.pattern=n),e._zod.onattach.push(s=>{const a=s._zod.bag;a.patterns??(a.patterns=new Set),a.patterns.add(n)}),e._zod.check=s=>{s.value.endsWith(t.suffix)||s.issues.push({origin:"string",code:"invalid_format",format:"ends_with",suffix:t.suffix,input:s.value,inst:e,continue:!t.abort})}}),Jn=h("$ZodCheckOverwrite",(e,t)=>{q.init(e,t),e._zod.check=n=>{n.value=t.tx(n.value)}}),Gn={major:4,minor:2,patch:1},L=h("$ZodType",(e,t)=>{var n;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=Gn;const s=[...e._zod.def.checks??[]];e._zod.traits.has("$ZodCheck")&&s.unshift(e);for(const a of s)for(const o of a._zod.onattach)o(e);if(s.length===0)(n=e._zod).deferred??(n.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{const a=(i,c,l)=>{let u=ce(i),d;for(const m of c){if(m._zod.def.when){if(!m._zod.def.when(i))continue}else if(u)continue;const p=i.issues.length,f=m._zod.check(i);if(f instanceof Promise&&l?.async===!1)throw new le;if(d||f instanceof Promise)d=(d??Promise.resolve()).then(async()=>{await f,i.issues.length!==p&&(u||(u=ce(i,p)))});else{if(i.issues.length===p)continue;u||(u=ce(i,p))}}return d?d.then(()=>i):i},o=(i,c,l)=>{if(ce(i))return i.aborted=!0,i;const u=a(c,s,l);if(u instanceof Promise){if(l.async===!1)throw new le;return u.then(d=>e._zod.parse(d,l))}return e._zod.parse(u,l)};e._zod.run=(i,c)=>{if(c.skipChecks)return e._zod.parse(i,c);if(c.direction==="backward"){const u=e._zod.parse({value:i.value,issues:[]},{...c,skipChecks:!0});return u instanceof Promise?u.then(d=>o(d,i,c)):o(u,i,c)}const l=e._zod.parse(i,c);if(l instanceof Promise){if(c.async===!1)throw new le;return l.then(u=>a(u,s,c))}return a(l,s,c)}}e["~standard"]={validate:a=>{try{const o=rn(e,a);return o.success?{value:o.data}:{issues:o.error?.issues}}catch{return nn(e,a).then(i=>i.success?{value:i.data}:{issues:i.error?.issues})}},vendor:"zod",version:1}}),tt=h("$ZodString",(e,t)=>{L.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??On(e._zod.bag),e._zod.parse=(n,s)=>{if(t.coerce)try{n.value=String(n.value)}catch{}return typeof n.value=="string"||n.issues.push({expected:"string",code:"invalid_type",input:n.value,inst:e}),n}}),A=h("$ZodStringFormat",(e,t)=>{Me.init(e,t),tt.init(e,t)}),Kn=h("$ZodGUID",(e,t)=>{t.pattern??(t.pattern=yn),A.init(e,t)}),Qn=h("$ZodUUID",(e,t)=>{if(t.version){const s={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[t.version];if(s===void 0)throw new Error(`Invalid UUID version: "${t.version}"`);t.pattern??(t.pattern=dt(s))}else t.pattern??(t.pattern=dt());A.init(e,t)}),Xn=h("$ZodEmail",(e,t)=>{t.pattern??(t.pattern=_n),A.init(e,t)}),es=h("$ZodURL",(e,t)=>{A.init(e,t),e._zod.check=n=>{try{const s=n.value.trim(),a=new URL(s);t.hostname&&(t.hostname.lastIndex=0,t.hostname.test(a.hostname)||n.issues.push({code:"invalid_format",format:"url",note:"Invalid hostname",pattern:t.hostname.source,input:n.value,inst:e,continue:!t.abort})),t.protocol&&(t.protocol.lastIndex=0,t.protocol.test(a.protocol.endsWith(":")?a.protocol.slice(0,-1):a.protocol)||n.issues.push({code:"invalid_format",format:"url",note:"Invalid protocol",pattern:t.protocol.source,input:n.value,inst:e,continue:!t.abort})),t.normalize?n.value=a.href:n.value=s;return}catch{n.issues.push({code:"invalid_format",format:"url",input:n.value,inst:e,continue:!t.abort})}}}),ts=h("$ZodEmoji",(e,t)=>{t.pattern??(t.pattern=wn()),A.init(e,t)}),rs=h("$ZodNanoID",(e,t)=>{t.pattern??(t.pattern=bn),A.init(e,t)}),ns=h("$ZodCUID",(e,t)=>{t.pattern??(t.pattern=hn),A.init(e,t)}),ss=h("$ZodCUID2",(e,t)=>{t.pattern??(t.pattern=pn),A.init(e,t)}),as=h("$ZodULID",(e,t)=>{t.pattern??(t.pattern=fn),A.init(e,t)}),os=h("$ZodXID",(e,t)=>{t.pattern??(t.pattern=gn),A.init(e,t)}),is=h("$ZodKSUID",(e,t)=>{t.pattern??(t.pattern=xn),A.init(e,t)}),cs=h("$ZodISODateTime",(e,t)=>{t.pattern??(t.pattern=Mn(t)),A.init(e,t)}),ls=h("$ZodISODate",(e,t)=>{t.pattern??(t.pattern=$n),A.init(e,t)}),us=h("$ZodISOTime",(e,t)=>{t.pattern??(t.pattern=zn(t)),A.init(e,t)}),ds=h("$ZodISODuration",(e,t)=>{t.pattern??(t.pattern=vn),A.init(e,t)}),ms=h("$ZodIPv4",(e,t)=>{t.pattern??(t.pattern=Nn),A.init(e,t),e._zod.bag.format="ipv4"}),hs=h("$ZodIPv6",(e,t)=>{t.pattern??(t.pattern=Sn),A.init(e,t),e._zod.bag.format="ipv6",e._zod.check=n=>{try{new URL(`http://[${n.value}]`)}catch{n.issues.push({code:"invalid_format",format:"ipv6",input:n.value,inst:e,continue:!t.abort})}}}),ps=h("$ZodCIDRv4",(e,t)=>{t.pattern??(t.pattern=Cn),A.init(e,t)}),fs=h("$ZodCIDRv6",(e,t)=>{t.pattern??(t.pattern=kn),A.init(e,t),e._zod.check=n=>{const s=n.value.split("/");try{if(s.length!==2)throw new Error;const[a,o]=s;if(!o)throw new Error;const i=Number(o);if(`${i}`!==o)throw new Error;if(i<0||i>128)throw new Error;new URL(`http://[${a}]`)}catch{n.issues.push({code:"invalid_format",format:"cidrv6",input:n.value,inst:e,continue:!t.abort})}}});function er(e){if(e==="")return!0;if(e.length%4!==0)return!1;try{return atob(e),!0}catch{return!1}}const gs=h("$ZodBase64",(e,t)=>{t.pattern??(t.pattern=Tn),A.init(e,t),e._zod.bag.contentEncoding="base64",e._zod.check=n=>{er(n.value)||n.issues.push({code:"invalid_format",format:"base64",input:n.value,inst:e,continue:!t.abort})}});function xs(e){if(!qt.test(e))return!1;const t=e.replace(/[-_]/g,s=>s==="-"?"+":"/"),n=t.padEnd(Math.ceil(t.length/4)*4,"=");return er(n)}const bs=h("$ZodBase64URL",(e,t)=>{t.pattern??(t.pattern=qt),A.init(e,t),e._zod.bag.contentEncoding="base64url",e._zod.check=n=>{xs(n.value)||n.issues.push({code:"invalid_format",format:"base64url",input:n.value,inst:e,continue:!t.abort})}}),vs=h("$ZodE164",(e,t)=>{t.pattern??(t.pattern=Pn),A.init(e,t)});function ys(e,t=null){try{const n=e.split(".");if(n.length!==3)return!1;const[s]=n;if(!s)return!1;const a=JSON.parse(atob(s));return!("typ"in a&&a?.typ!=="JWT"||!a.alg||t&&(!("alg"in a)||a.alg!==t))}catch{return!1}}const _s=h("$ZodJWT",(e,t)=>{A.init(e,t),e._zod.check=n=>{ys(n.value,t.alg)||n.issues.push({code:"invalid_format",format:"jwt",input:n.value,inst:e,continue:!t.abort})}}),tr=h("$ZodNumber",(e,t)=>{L.init(e,t),e._zod.pattern=e._zod.bag.pattern??An,e._zod.parse=(n,s)=>{if(t.coerce)try{n.value=Number(n.value)}catch{}const a=n.value;if(typeof a=="number"&&!Number.isNaN(a)&&Number.isFinite(a))return n;const o=typeof a=="number"?Number.isNaN(a)?"NaN":Number.isFinite(a)?void 0:"Infinity":void 0;return n.issues.push({expected:"number",code:"invalid_type",input:a,inst:e,...o?{received:o}:{}}),n}}),js=h("$ZodNumberFormat",(e,t)=>{Fn.init(e,t),tr.init(e,t)});function mt(e,t,n){e.issues.length&&t.issues.push(...Xr(n,e.issues)),t.value[n]=e.value}const ws=h("$ZodArray",(e,t)=>{L.init(e,t),e._zod.parse=(n,s)=>{const a=n.value;if(!Array.isArray(a))return n.issues.push({expected:"array",code:"invalid_type",input:a,inst:e}),n;n.value=Array(a.length);const o=[];for(let i=0;imt(u,n,i))):mt(l,n,i)}return o.length?Promise.all(o).then(()=>n):n}});function ht(e,t,n,s){for(const o of e)if(o.issues.length===0)return t.value=o.value,t;const a=e.filter(o=>!ce(o));return a.length===1?(t.value=a[0].value,a[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:n,errors:e.map(o=>o.issues.map(i=>ae(i,s,se())))}),t)}const Ns=h("$ZodUnion",(e,t)=>{L.init(e,t),I(e._zod,"optin",()=>t.options.some(a=>a._zod.optin==="optional")?"optional":void 0),I(e._zod,"optout",()=>t.options.some(a=>a._zod.optout==="optional")?"optional":void 0),I(e._zod,"values",()=>{if(t.options.every(a=>a._zod.values))return new Set(t.options.flatMap(a=>Array.from(a._zod.values)))}),I(e._zod,"pattern",()=>{if(t.options.every(a=>a._zod.pattern)){const a=t.options.map(o=>o._zod.pattern);return new RegExp(`^(${a.map(o=>Ge(o.source)).join("|")})$`)}});const n=t.options.length===1,s=t.options[0]._zod.run;e._zod.parse=(a,o)=>{if(n)return s(a,o);let i=!1;const c=[];for(const l of t.options){const u=l._zod.run({value:a.value,issues:[]},o);if(u instanceof Promise)c.push(u),i=!0;else{if(u.issues.length===0)return u;c.push(u)}}return i?Promise.all(c).then(l=>ht(l,a,e,o)):ht(c,a,e,o)}}),Ss=h("$ZodIntersection",(e,t)=>{L.init(e,t),e._zod.parse=(n,s)=>{const a=n.value,o=t.left._zod.run({value:a,issues:[]},s),i=t.right._zod.run({value:a,issues:[]},s);return o instanceof Promise||i instanceof Promise?Promise.all([o,i]).then(([l,u])=>pt(n,l,u)):pt(n,o,i)}});function He(e,t){if(e===t)return{valid:!0,data:e};if(e instanceof Date&&t instanceof Date&&+e==+t)return{valid:!0,data:e};if(Ue(e)&&Ue(t)){const n=Object.keys(t),s=Object.keys(e).filter(o=>n.indexOf(o)!==-1),a={...e,...t};for(const o of s){const i=He(e[o],t[o]);if(!i.valid)return{valid:!1,mergeErrorPath:[o,...i.mergeErrorPath]};a[o]=i.data}return{valid:!0,data:a}}if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return{valid:!1,mergeErrorPath:[]};const n=[];for(let s=0;s{L.init(e,t),e._zod.parse=(n,s)=>{if(s.direction==="backward")throw new Ut(e.constructor.name);const a=t.transform(n.value,n);if(s.async)return(a instanceof Promise?a:Promise.resolve(a)).then(i=>(n.value=i,n));if(a instanceof Promise)throw new le;return n.value=a,n}});function ft(e,t){return e.issues.length&&t===void 0?{issues:[],value:void 0}:e}const ks=h("$ZodOptional",(e,t)=>{L.init(e,t),e._zod.optin="optional",e._zod.optout="optional",I(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),I(e._zod,"pattern",()=>{const n=t.innerType._zod.pattern;return n?new RegExp(`^(${Ge(n.source)})?$`):void 0}),e._zod.parse=(n,s)=>{if(t.innerType._zod.optin==="optional"){const a=t.innerType._zod.run(n,s);return a instanceof Promise?a.then(o=>ft(o,n.value)):ft(a,n.value)}return n.value===void 0?n:t.innerType._zod.run(n,s)}}),Ts=h("$ZodNullable",(e,t)=>{L.init(e,t),I(e._zod,"optin",()=>t.innerType._zod.optin),I(e._zod,"optout",()=>t.innerType._zod.optout),I(e._zod,"pattern",()=>{const n=t.innerType._zod.pattern;return n?new RegExp(`^(${Ge(n.source)}|null)$`):void 0}),I(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(n,s)=>n.value===null?n:t.innerType._zod.run(n,s)}),Ps=h("$ZodDefault",(e,t)=>{L.init(e,t),e._zod.optin="optional",I(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(n,s)=>{if(s.direction==="backward")return t.innerType._zod.run(n,s);if(n.value===void 0)return n.value=t.defaultValue,n;const a=t.innerType._zod.run(n,s);return a instanceof Promise?a.then(o=>gt(o,t)):gt(a,t)}});function gt(e,t){return e.value===void 0&&(e.value=t.defaultValue),e}const $s=h("$ZodPrefault",(e,t)=>{L.init(e,t),e._zod.optin="optional",I(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(n,s)=>(s.direction==="backward"||n.value===void 0&&(n.value=t.defaultValue),t.innerType._zod.run(n,s))}),zs=h("$ZodNonOptional",(e,t)=>{L.init(e,t),I(e._zod,"values",()=>{const n=t.innerType._zod.values;return n?new Set([...n].filter(s=>s!==void 0)):void 0}),e._zod.parse=(n,s)=>{const a=t.innerType._zod.run(n,s);return a instanceof Promise?a.then(o=>xt(o,e)):xt(a,e)}});function xt(e,t){return!e.issues.length&&e.value===void 0&&e.issues.push({code:"invalid_type",expected:"nonoptional",input:e.value,inst:t}),e}const Ms=h("$ZodCatch",(e,t)=>{L.init(e,t),I(e._zod,"optin",()=>t.innerType._zod.optin),I(e._zod,"optout",()=>t.innerType._zod.optout),I(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(n,s)=>{if(s.direction==="backward")return t.innerType._zod.run(n,s);const a=t.innerType._zod.run(n,s);return a instanceof Promise?a.then(o=>(n.value=o.value,o.issues.length&&(n.value=t.catchValue({...n,error:{issues:o.issues.map(i=>ae(i,s,se()))},input:n.value}),n.issues=[]),n)):(n.value=a.value,a.issues.length&&(n.value=t.catchValue({...n,error:{issues:a.issues.map(o=>ae(o,s,se()))},input:n.value}),n.issues=[]),n)}}),Os=h("$ZodPipe",(e,t)=>{L.init(e,t),I(e._zod,"values",()=>t.in._zod.values),I(e._zod,"optin",()=>t.in._zod.optin),I(e._zod,"optout",()=>t.out._zod.optout),I(e._zod,"propValues",()=>t.in._zod.propValues),e._zod.parse=(n,s)=>{if(s.direction==="backward"){const o=t.out._zod.run(n,s);return o instanceof Promise?o.then(i=>je(i,t.in,s)):je(o,t.in,s)}const a=t.in._zod.run(n,s);return a instanceof Promise?a.then(o=>je(o,t.out,s)):je(a,t.out,s)}});function je(e,t,n){return e.issues.length?(e.aborted=!0,e):t._zod.run({value:e.value,issues:e.issues},n)}const Is=h("$ZodReadonly",(e,t)=>{L.init(e,t),I(e._zod,"propValues",()=>t.innerType._zod.propValues),I(e._zod,"values",()=>t.innerType._zod.values),I(e._zod,"optin",()=>t.innerType?._zod?.optin),I(e._zod,"optout",()=>t.innerType?._zod?.optout),e._zod.parse=(n,s)=>{if(s.direction==="backward")return t.innerType._zod.run(n,s);const a=t.innerType._zod.run(n,s);return a instanceof Promise?a.then(bt):bt(a)}});function bt(e){return e.value=Object.freeze(e.value),e}const As=h("$ZodCustom",(e,t)=>{q.init(e,t),L.init(e,t),e._zod.parse=(n,s)=>n,e._zod.check=n=>{const s=n.value,a=t.fn(s);if(a instanceof Promise)return a.then(o=>vt(o,n,s,e));vt(a,n,s,e)}});function vt(e,t,n,s){if(!e){const a={code:"custom",input:n,inst:s,path:[...s._zod.def.path??[]],continue:!s._zod.def.abort};s._zod.def.params&&(a.params=s._zod.def.params),t.issues.push(xe(a))}}var yt;class Ds{constructor(){this._map=new WeakMap,this._idmap=new Map}add(t,...n){const s=n[0];if(this._map.set(t,s),s&&typeof s=="object"&&"id"in s){if(this._idmap.has(s.id))throw new Error(`ID ${s.id} already exists in the registry`);this._idmap.set(s.id,t)}return this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(t){const n=this._map.get(t);return n&&typeof n=="object"&&"id"in n&&this._idmap.delete(n.id),this._map.delete(t),this}get(t){const n=t._zod.parent;if(n){const s={...this.get(n)??{}};delete s.id;const a={...s,...this._map.get(t)};return Object.keys(a).length?a:void 0}return this._map.get(t)}has(t){return this._map.has(t)}}function Es(){return new Ds}(yt=globalThis).__zod_globalRegistry??(yt.__zod_globalRegistry=Es());const ge=globalThis.__zod_globalRegistry;function Rs(e,t){return new e({type:"string",...j(t)})}function Fs(e,t){return new e({type:"string",format:"email",check:"string_format",abort:!1,...j(t)})}function _t(e,t){return new e({type:"string",format:"guid",check:"string_format",abort:!1,...j(t)})}function Zs(e,t){return new e({type:"string",format:"uuid",check:"string_format",abort:!1,...j(t)})}function Ls(e,t){return new e({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v4",...j(t)})}function Us(e,t){return new e({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v6",...j(t)})}function Hs(e,t){return new e({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v7",...j(t)})}function Vs(e,t){return new e({type:"string",format:"url",check:"string_format",abort:!1,...j(t)})}function Bs(e,t){return new e({type:"string",format:"emoji",check:"string_format",abort:!1,...j(t)})}function Ws(e,t){return new e({type:"string",format:"nanoid",check:"string_format",abort:!1,...j(t)})}function Ys(e,t){return new e({type:"string",format:"cuid",check:"string_format",abort:!1,...j(t)})}function qs(e,t){return new e({type:"string",format:"cuid2",check:"string_format",abort:!1,...j(t)})}function Js(e,t){return new e({type:"string",format:"ulid",check:"string_format",abort:!1,...j(t)})}function Gs(e,t){return new e({type:"string",format:"xid",check:"string_format",abort:!1,...j(t)})}function Ks(e,t){return new e({type:"string",format:"ksuid",check:"string_format",abort:!1,...j(t)})}function Qs(e,t){return new e({type:"string",format:"ipv4",check:"string_format",abort:!1,...j(t)})}function Xs(e,t){return new e({type:"string",format:"ipv6",check:"string_format",abort:!1,...j(t)})}function ea(e,t){return new e({type:"string",format:"cidrv4",check:"string_format",abort:!1,...j(t)})}function ta(e,t){return new e({type:"string",format:"cidrv6",check:"string_format",abort:!1,...j(t)})}function ra(e,t){return new e({type:"string",format:"base64",check:"string_format",abort:!1,...j(t)})}function na(e,t){return new e({type:"string",format:"base64url",check:"string_format",abort:!1,...j(t)})}function sa(e,t){return new e({type:"string",format:"e164",check:"string_format",abort:!1,...j(t)})}function aa(e,t){return new e({type:"string",format:"jwt",check:"string_format",abort:!1,...j(t)})}function oa(e,t){return new e({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...j(t)})}function ia(e,t){return new e({type:"string",format:"date",check:"string_format",...j(t)})}function ca(e,t){return new e({type:"string",format:"time",check:"string_format",precision:null,...j(t)})}function la(e,t){return new e({type:"string",format:"duration",check:"string_format",...j(t)})}function ua(e,t){return new e({type:"number",coerce:!0,checks:[],...j(t)})}function da(e,t){return new e({type:"number",check:"number_format",abort:!1,format:"safeint",...j(t)})}function jt(e,t){return new Qt({check:"less_than",...j(t),value:e,inclusive:!1})}function Ae(e,t){return new Qt({check:"less_than",...j(t),value:e,inclusive:!0})}function wt(e,t){return new Xt({check:"greater_than",...j(t),value:e,inclusive:!1})}function De(e,t){return new Xt({check:"greater_than",...j(t),value:e,inclusive:!0})}function Nt(e,t){return new Rn({check:"multiple_of",...j(t),value:e})}function rr(e,t){return new Zn({check:"max_length",...j(t),maximum:e})}function ke(e,t){return new Ln({check:"min_length",...j(t),minimum:e})}function nr(e,t){return new Un({check:"length_equals",...j(t),length:e})}function ma(e,t){return new Hn({check:"string_format",format:"regex",...j(t),pattern:e})}function ha(e){return new Vn({check:"string_format",format:"lowercase",...j(e)})}function pa(e){return new Bn({check:"string_format",format:"uppercase",...j(e)})}function fa(e,t){return new Wn({check:"string_format",format:"includes",...j(t),includes:e})}function ga(e,t){return new Yn({check:"string_format",format:"starts_with",...j(t),prefix:e})}function xa(e,t){return new qn({check:"string_format",format:"ends_with",...j(t),suffix:e})}function ue(e){return new Jn({check:"overwrite",tx:e})}function ba(e){return ue(t=>t.normalize(e))}function va(){return ue(e=>e.trim())}function ya(){return ue(e=>e.toLowerCase())}function _a(){return ue(e=>e.toUpperCase())}function ja(){return ue(e=>Gr(e))}function wa(e,t,n){return new e({type:"array",element:t,...j(n)})}function Na(e,t,n){return new e({type:"custom",check:"custom",fn:t,...j(n)})}function Sa(e){const t=Ca(n=>(n.addIssue=s=>{if(typeof s=="string")n.issues.push(xe(s,n.value,t._zod.def));else{const a=s;a.fatal&&(a.continue=!1),a.code??(a.code="custom"),a.input??(a.input=n.value),a.inst??(a.inst=t),a.continue??(a.continue=!t._zod.def.abort),n.issues.push(xe(a))}},e(n.value,n)));return t}function Ca(e,t){const n=new q({check:"custom",...j(t)});return n._zod.check=e,n}function sr(e){let t=e?.target??"draft-2020-12";return t==="draft-4"&&(t="draft-04"),t==="draft-7"&&(t="draft-07"),{processors:e.processors??{},metadataRegistry:e?.metadata??ge,target:t,unrepresentable:e?.unrepresentable??"throw",override:e?.override??(()=>{}),io:e?.io??"output",counter:0,seen:new Map,cycles:e?.cycles??"ref",reused:e?.reused??"inline",external:e?.external??void 0}}function H(e,t,n={path:[],schemaPath:[]}){var s;const a=e._zod.def,o=t.seen.get(e);if(o)return o.count++,n.schemaPath.includes(e)&&(o.cycle=n.path),o.schema;const i={schema:{},count:1,cycle:void 0,path:n.path};t.seen.set(e,i);const c=e._zod.toJSONSchema?.();if(c)i.schema=c;else{const d={...n,schemaPath:[...n.schemaPath,e],path:n.path},m=e._zod.parent;if(m)i.ref=m,H(m,t,d),t.seen.get(m).isParent=!0;else if(e._zod.processJSONSchema)e._zod.processJSONSchema(t,i.schema,d);else{const p=i.schema,f=t.processors[a.type];if(!f)throw new Error(`[toJSONSchema]: Non-representable type encountered: ${a.type}`);f(e,t,p,d)}}const l=t.metadataRegistry.get(e);return l&&Object.assign(i.schema,l),t.io==="input"&&U(e)&&(delete i.schema.examples,delete i.schema.default),t.io==="input"&&i.schema._prefault&&((s=i.schema).default??(s.default=i.schema._prefault)),delete i.schema._prefault,t.seen.get(e).schema}function ar(e,t){const n=e.seen.get(t);if(!n)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=o=>{const i=e.target==="draft-2020-12"?"$defs":"definitions";if(e.external){const d=e.external.registry.get(o[0])?.id,m=e.external.uri??(f=>f);if(d)return{ref:m(d)};const p=o[1].defId??o[1].schema.id??`schema${e.counter++}`;return o[1].defId=p,{defId:p,ref:`${m("__shared")}#/${i}/${p}`}}if(o[1]===n)return{ref:"#"};const l=`#/${i}/`,u=o[1].schema.id??`__schema${e.counter++}`;return{defId:u,ref:l+u}},a=o=>{if(o[1].schema.$ref)return;const i=o[1],{ref:c,defId:l}=s(o);i.def={...i.schema},l&&(i.defId=l);const u=i.schema;for(const d in u)delete u[d];u.$ref=c};if(e.cycles==="throw")for(const o of e.seen.entries()){const i=o[1];if(i.cycle)throw new Error(`Cycle detected: #/${i.cycle?.join("/")}/ + +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const o of e.seen.entries()){const i=o[1];if(t===o[0]){a(o);continue}if(e.external){const l=e.external.registry.get(o[0])?.id;if(t!==o[0]&&l){a(o);continue}}if(e.metadataRegistry.get(o[0])?.id){a(o);continue}if(i.cycle){a(o);continue}if(i.count>1&&e.reused==="ref"){a(o);continue}}}function or(e,t){const n=e.seen.get(t);if(!n)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=i=>{const c=e.seen.get(i),l=c.def??c.schema,u={...l};if(c.ref===null)return;const d=c.ref;if(c.ref=null,d){s(d);const m=e.seen.get(d).schema;m.$ref&&(e.target==="draft-07"||e.target==="draft-04"||e.target==="openapi-3.0")?(l.allOf=l.allOf??[],l.allOf.push(m)):(Object.assign(l,m),Object.assign(l,u))}c.isParent||e.override({zodSchema:i,jsonSchema:l,path:c.path??[]})};for(const i of[...e.seen.entries()].reverse())s(i[0]);const a={};if(e.target==="draft-2020-12"?a.$schema="https://json-schema.org/draft/2020-12/schema":e.target==="draft-07"?a.$schema="http://json-schema.org/draft-07/schema#":e.target==="draft-04"?a.$schema="http://json-schema.org/draft-04/schema#":e.target,e.external?.uri){const i=e.external.registry.get(t)?.id;if(!i)throw new Error("Schema is missing an `id` property");a.$id=e.external.uri(i)}Object.assign(a,n.def??n.schema);const o=e.external?.defs??{};for(const i of e.seen.entries()){const c=i[1];c.def&&c.defId&&(o[c.defId]=c.def)}e.external||Object.keys(o).length>0&&(e.target==="draft-2020-12"?a.$defs=o:a.definitions=o);try{const i=JSON.parse(JSON.stringify(a));return Object.defineProperty(i,"~standard",{value:{...t["~standard"],jsonSchema:{input:Te(t,"input"),output:Te(t,"output")}},enumerable:!1,writable:!1}),i}catch{throw new Error("Error converting schema to JSON.")}}function U(e,t){const n=t??{seen:new Set};if(n.seen.has(e))return!1;n.seen.add(e);const s=e._zod.def;if(s.type==="transform")return!0;if(s.type==="array")return U(s.element,n);if(s.type==="set")return U(s.valueType,n);if(s.type==="lazy")return U(s.getter(),n);if(s.type==="promise"||s.type==="optional"||s.type==="nonoptional"||s.type==="nullable"||s.type==="readonly"||s.type==="default"||s.type==="prefault")return U(s.innerType,n);if(s.type==="intersection")return U(s.left,n)||U(s.right,n);if(s.type==="record"||s.type==="map")return U(s.keyType,n)||U(s.valueType,n);if(s.type==="pipe")return U(s.in,n)||U(s.out,n);if(s.type==="object"){for(const a in s.shape)if(U(s.shape[a],n))return!0;return!1}if(s.type==="union"){for(const a of s.options)if(U(a,n))return!0;return!1}if(s.type==="tuple"){for(const a of s.items)if(U(a,n))return!0;return!!(s.rest&&U(s.rest,n))}return!1}const ka=(e,t={})=>n=>{const s=sr({...n,processors:t});return H(e,s),ar(s,e),or(s,e)},Te=(e,t)=>n=>{const{libraryOptions:s,target:a}=n??{},o=sr({...s??{},target:a,io:t,processors:{}});return H(e,o),ar(o,e),or(o,e)},Ta={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},Pa=(e,t,n,s)=>{const a=n;a.type="string";const{minimum:o,maximum:i,format:c,patterns:l,contentEncoding:u}=e._zod.bag;if(typeof o=="number"&&(a.minLength=o),typeof i=="number"&&(a.maxLength=i),c&&(a.format=Ta[c]??c,a.format===""&&delete a.format),u&&(a.contentEncoding=u),l&&l.size>0){const d=[...l];d.length===1?a.pattern=d[0].source:d.length>1&&(a.allOf=[...d.map(m=>({...t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0"?{type:"string"}:{},pattern:m.source}))])}},$a=(e,t,n,s)=>{const a=n,{minimum:o,maximum:i,format:c,multipleOf:l,exclusiveMaximum:u,exclusiveMinimum:d}=e._zod.bag;typeof c=="string"&&c.includes("int")?a.type="integer":a.type="number",typeof d=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(a.minimum=d,a.exclusiveMinimum=!0):a.exclusiveMinimum=d),typeof o=="number"&&(a.minimum=o,typeof d=="number"&&t.target!=="draft-04"&&(d>=o?delete a.minimum:delete a.exclusiveMinimum)),typeof u=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(a.maximum=u,a.exclusiveMaximum=!0):a.exclusiveMaximum=u),typeof i=="number"&&(a.maximum=i,typeof u=="number"&&t.target!=="draft-04"&&(u<=i?delete a.maximum:delete a.exclusiveMaximum)),typeof l=="number"&&(a.multipleOf=l)},za=(e,t,n,s)=>{if(t.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},Ma=(e,t,n,s)=>{if(t.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},Oa=(e,t,n,s)=>{const a=n,o=e._zod.def,{minimum:i,maximum:c}=e._zod.bag;typeof i=="number"&&(a.minItems=i),typeof c=="number"&&(a.maxItems=c),a.type="array",a.items=H(o.element,t,{...s,path:[...s.path,"items"]})},Ia=(e,t,n,s)=>{const a=e._zod.def,o=a.inclusive===!1,i=a.options.map((c,l)=>H(c,t,{...s,path:[...s.path,o?"oneOf":"anyOf",l]}));o?n.oneOf=i:n.anyOf=i},Aa=(e,t,n,s)=>{const a=e._zod.def,o=H(a.left,t,{...s,path:[...s.path,"allOf",0]}),i=H(a.right,t,{...s,path:[...s.path,"allOf",1]}),c=u=>"allOf"in u&&Object.keys(u).length===1,l=[...c(o)?o.allOf:[o],...c(i)?i.allOf:[i]];n.allOf=l},Da=(e,t,n,s)=>{const a=e._zod.def,o=H(a.innerType,t,s),i=t.seen.get(e);t.target==="openapi-3.0"?(i.ref=a.innerType,n.nullable=!0):n.anyOf=[o,{type:"null"}]},Ea=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType},Ra=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType,n.default=JSON.parse(JSON.stringify(a.defaultValue))},Fa=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType,t.io==="input"&&(n._prefault=JSON.parse(JSON.stringify(a.defaultValue)))},Za=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType;let i;try{i=a.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}n.default=i},La=(e,t,n,s)=>{const a=e._zod.def,o=t.io==="input"?a.in._zod.def.type==="transform"?a.out:a.in:a.out;H(o,t,s);const i=t.seen.get(e);i.ref=o},Ua=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType,n.readOnly=!0},Ha=(e,t,n,s)=>{const a=e._zod.def;H(a.innerType,t,s);const o=t.seen.get(e);o.ref=a.innerType},Va=h("ZodISODateTime",(e,t)=>{cs.init(e,t),E.init(e,t)});function Ba(e){return oa(Va,e)}const Wa=h("ZodISODate",(e,t)=>{ls.init(e,t),E.init(e,t)});function Ya(e){return ia(Wa,e)}const qa=h("ZodISOTime",(e,t)=>{us.init(e,t),E.init(e,t)});function Ja(e){return ca(qa,e)}const Ga=h("ZodISODuration",(e,t)=>{ds.init(e,t),E.init(e,t)});function Ka(e){return la(Ga,e)}const Qa=(e,t)=>{Wt.init(e,t),e.name="ZodError",Object.defineProperties(e,{format:{value:n=>tn(e,n)},flatten:{value:n=>en(e,n)},addIssue:{value:n=>{e.issues.push(n),e.message=JSON.stringify(e.issues,Le,2)}},addIssues:{value:n=>{e.issues.push(...n),e.message=JSON.stringify(e.issues,Le,2)}},isEmpty:{get(){return e.issues.length===0}}})},K=h("ZodError",Qa,{Parent:Error}),Xa=Xe(K),eo=et(K),to=$e(K),ro=ze(K),no=sn(K),so=an(K),ao=on(K),oo=cn(K),io=ln(K),co=un(K),lo=dn(K),uo=mn(K),V=h("ZodType",(e,t)=>(L.init(e,t),Object.assign(e["~standard"],{jsonSchema:{input:Te(e,"input"),output:Te(e,"output")}}),e.toJSONSchema=ka(e,{}),e.def=t,e.type=t.type,Object.defineProperty(e,"_def",{value:t}),e.check=(...n)=>e.clone(Jr(t,{checks:[...t.checks??[],...n.map(s=>typeof s=="function"?{_zod:{check:s,def:{check:"custom"},onattach:[]}}:s)]})),e.clone=(n,s)=>Kr(e,n,s),e.brand=()=>e,e.register=((n,s)=>(n.add(e,s),e)),e.parse=(n,s)=>Xa(e,n,s,{callee:e.parse}),e.safeParse=(n,s)=>to(e,n,s),e.parseAsync=async(n,s)=>eo(e,n,s,{callee:e.parseAsync}),e.safeParseAsync=async(n,s)=>ro(e,n,s),e.spa=e.safeParseAsync,e.encode=(n,s)=>no(e,n,s),e.decode=(n,s)=>so(e,n,s),e.encodeAsync=async(n,s)=>ao(e,n,s),e.decodeAsync=async(n,s)=>oo(e,n,s),e.safeEncode=(n,s)=>io(e,n,s),e.safeDecode=(n,s)=>co(e,n,s),e.safeEncodeAsync=async(n,s)=>lo(e,n,s),e.safeDecodeAsync=async(n,s)=>uo(e,n,s),e.refine=(n,s)=>e.check(Xo(n,s)),e.superRefine=n=>e.check(ei(n)),e.overwrite=n=>e.check(ue(n)),e.optional=()=>kt(e),e.nullable=()=>Tt(e),e.nullish=()=>kt(Tt(e)),e.nonoptional=n=>Wo(e,n),e.array=()=>Mo(e),e.or=n=>Io([e,n]),e.and=n=>Do(e,n),e.transform=n=>Pt(e,Ro(n)),e.default=n=>Uo(e,n),e.prefault=n=>Vo(e,n),e.catch=n=>qo(e,n),e.pipe=n=>Pt(e,n),e.readonly=()=>Ko(e),e.describe=n=>{const s=e.clone();return ge.add(s,{description:n}),s},Object.defineProperty(e,"description",{get(){return ge.get(e)?.description},configurable:!0}),e.meta=(...n)=>{if(n.length===0)return ge.get(e);const s=e.clone();return ge.add(s,n[0]),s},e.isOptional=()=>e.safeParse(void 0).success,e.isNullable=()=>e.safeParse(null).success,e)),ir=h("_ZodString",(e,t)=>{tt.init(e,t),V.init(e,t),e._zod.processJSONSchema=(s,a,o)=>Pa(e,s,a);const n=e._zod.bag;e.format=n.format??null,e.minLength=n.minimum??null,e.maxLength=n.maximum??null,e.regex=(...s)=>e.check(ma(...s)),e.includes=(...s)=>e.check(fa(...s)),e.startsWith=(...s)=>e.check(ga(...s)),e.endsWith=(...s)=>e.check(xa(...s)),e.min=(...s)=>e.check(ke(...s)),e.max=(...s)=>e.check(rr(...s)),e.length=(...s)=>e.check(nr(...s)),e.nonempty=(...s)=>e.check(ke(1,...s)),e.lowercase=s=>e.check(ha(s)),e.uppercase=s=>e.check(pa(s)),e.trim=()=>e.check(va()),e.normalize=(...s)=>e.check(ba(...s)),e.toLowerCase=()=>e.check(ya()),e.toUpperCase=()=>e.check(_a()),e.slugify=()=>e.check(ja())}),mo=h("ZodString",(e,t)=>{tt.init(e,t),ir.init(e,t),e.email=n=>e.check(Fs(ho,n)),e.url=n=>e.check(Vs(po,n)),e.jwt=n=>e.check(aa(Po,n)),e.emoji=n=>e.check(Bs(fo,n)),e.guid=n=>e.check(_t(St,n)),e.uuid=n=>e.check(Zs(we,n)),e.uuidv4=n=>e.check(Ls(we,n)),e.uuidv6=n=>e.check(Us(we,n)),e.uuidv7=n=>e.check(Hs(we,n)),e.nanoid=n=>e.check(Ws(go,n)),e.guid=n=>e.check(_t(St,n)),e.cuid=n=>e.check(Ys(xo,n)),e.cuid2=n=>e.check(qs(bo,n)),e.ulid=n=>e.check(Js(vo,n)),e.base64=n=>e.check(ra(Co,n)),e.base64url=n=>e.check(na(ko,n)),e.xid=n=>e.check(Gs(yo,n)),e.ksuid=n=>e.check(Ks(_o,n)),e.ipv4=n=>e.check(Qs(jo,n)),e.ipv6=n=>e.check(Xs(wo,n)),e.cidrv4=n=>e.check(ea(No,n)),e.cidrv6=n=>e.check(ta(So,n)),e.e164=n=>e.check(sa(To,n)),e.datetime=n=>e.check(Ba(n)),e.date=n=>e.check(Ya(n)),e.time=n=>e.check(Ja(n)),e.duration=n=>e.check(Ka(n))});function ve(e){return Rs(mo,e)}const E=h("ZodStringFormat",(e,t)=>{A.init(e,t),ir.init(e,t)}),ho=h("ZodEmail",(e,t)=>{Xn.init(e,t),E.init(e,t)}),St=h("ZodGUID",(e,t)=>{Kn.init(e,t),E.init(e,t)}),we=h("ZodUUID",(e,t)=>{Qn.init(e,t),E.init(e,t)}),po=h("ZodURL",(e,t)=>{es.init(e,t),E.init(e,t)}),fo=h("ZodEmoji",(e,t)=>{ts.init(e,t),E.init(e,t)}),go=h("ZodNanoID",(e,t)=>{rs.init(e,t),E.init(e,t)}),xo=h("ZodCUID",(e,t)=>{ns.init(e,t),E.init(e,t)}),bo=h("ZodCUID2",(e,t)=>{ss.init(e,t),E.init(e,t)}),vo=h("ZodULID",(e,t)=>{as.init(e,t),E.init(e,t)}),yo=h("ZodXID",(e,t)=>{os.init(e,t),E.init(e,t)}),_o=h("ZodKSUID",(e,t)=>{is.init(e,t),E.init(e,t)}),jo=h("ZodIPv4",(e,t)=>{ms.init(e,t),E.init(e,t)}),wo=h("ZodIPv6",(e,t)=>{hs.init(e,t),E.init(e,t)}),No=h("ZodCIDRv4",(e,t)=>{ps.init(e,t),E.init(e,t)}),So=h("ZodCIDRv6",(e,t)=>{fs.init(e,t),E.init(e,t)}),Co=h("ZodBase64",(e,t)=>{gs.init(e,t),E.init(e,t)}),ko=h("ZodBase64URL",(e,t)=>{bs.init(e,t),E.init(e,t)}),To=h("ZodE164",(e,t)=>{vs.init(e,t),E.init(e,t)}),Po=h("ZodJWT",(e,t)=>{_s.init(e,t),E.init(e,t)}),cr=h("ZodNumber",(e,t)=>{tr.init(e,t),V.init(e,t),e._zod.processJSONSchema=(s,a,o)=>$a(e,s,a),e.gt=(s,a)=>e.check(wt(s,a)),e.gte=(s,a)=>e.check(De(s,a)),e.min=(s,a)=>e.check(De(s,a)),e.lt=(s,a)=>e.check(jt(s,a)),e.lte=(s,a)=>e.check(Ae(s,a)),e.max=(s,a)=>e.check(Ae(s,a)),e.int=s=>e.check(Ct(s)),e.safe=s=>e.check(Ct(s)),e.positive=s=>e.check(wt(0,s)),e.nonnegative=s=>e.check(De(0,s)),e.negative=s=>e.check(jt(0,s)),e.nonpositive=s=>e.check(Ae(0,s)),e.multipleOf=(s,a)=>e.check(Nt(s,a)),e.step=(s,a)=>e.check(Nt(s,a)),e.finite=()=>e;const n=e._zod.bag;e.minValue=Math.max(n.minimum??Number.NEGATIVE_INFINITY,n.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,e.maxValue=Math.min(n.maximum??Number.POSITIVE_INFINITY,n.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,e.isInt=(n.format??"").includes("int")||Number.isSafeInteger(n.multipleOf??.5),e.isFinite=!0,e.format=n.format??null}),$o=h("ZodNumberFormat",(e,t)=>{js.init(e,t),cr.init(e,t)});function Ct(e){return da($o,e)}const zo=h("ZodArray",(e,t)=>{ws.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Oa(e,n,s,a),e.element=t.element,e.min=(n,s)=>e.check(ke(n,s)),e.nonempty=n=>e.check(ke(1,n)),e.max=(n,s)=>e.check(rr(n,s)),e.length=(n,s)=>e.check(nr(n,s)),e.unwrap=()=>e.element});function Mo(e,t){return wa(zo,e,t)}const Oo=h("ZodUnion",(e,t)=>{Ns.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ia(e,n,s,a),e.options=t.options});function Io(e,t){return new Oo({type:"union",options:e,...j(t)})}const Ao=h("ZodIntersection",(e,t)=>{Ss.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Aa(e,n,s,a)});function Do(e,t){return new Ao({type:"intersection",left:e,right:t})}const Eo=h("ZodTransform",(e,t)=>{Cs.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ma(e,n),e._zod.parse=(n,s)=>{if(s.direction==="backward")throw new Ut(e.constructor.name);n.addIssue=o=>{if(typeof o=="string")n.issues.push(xe(o,n.value,t));else{const i=o;i.fatal&&(i.continue=!1),i.code??(i.code="custom"),i.input??(i.input=n.value),i.inst??(i.inst=e),n.issues.push(xe(i))}};const a=t.transform(n.value,n);return a instanceof Promise?a.then(o=>(n.value=o,n)):(n.value=a,n)}});function Ro(e){return new Eo({type:"transform",transform:e})}const Fo=h("ZodOptional",(e,t)=>{ks.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ha(e,n,s,a),e.unwrap=()=>e._zod.def.innerType});function kt(e){return new Fo({type:"optional",innerType:e})}const Zo=h("ZodNullable",(e,t)=>{Ts.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Da(e,n,s,a),e.unwrap=()=>e._zod.def.innerType});function Tt(e){return new Zo({type:"nullable",innerType:e})}const Lo=h("ZodDefault",(e,t)=>{Ps.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ra(e,n,s,a),e.unwrap=()=>e._zod.def.innerType,e.removeDefault=e.unwrap});function Uo(e,t){return new Lo({type:"default",innerType:e,get defaultValue(){return typeof t=="function"?t():Vt(t)}})}const Ho=h("ZodPrefault",(e,t)=>{$s.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Fa(e,n,s,a),e.unwrap=()=>e._zod.def.innerType});function Vo(e,t){return new Ho({type:"prefault",innerType:e,get defaultValue(){return typeof t=="function"?t():Vt(t)}})}const Bo=h("ZodNonOptional",(e,t)=>{zs.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ea(e,n,s,a),e.unwrap=()=>e._zod.def.innerType});function Wo(e,t){return new Bo({type:"nonoptional",innerType:e,...j(t)})}const Yo=h("ZodCatch",(e,t)=>{Ms.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Za(e,n,s,a),e.unwrap=()=>e._zod.def.innerType,e.removeCatch=e.unwrap});function qo(e,t){return new Yo({type:"catch",innerType:e,catchValue:typeof t=="function"?t:()=>t})}const Jo=h("ZodPipe",(e,t)=>{Os.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>La(e,n,s,a),e.in=t.in,e.out=t.out});function Pt(e,t){return new Jo({type:"pipe",in:e,out:t})}const Go=h("ZodReadonly",(e,t)=>{Is.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>Ua(e,n,s,a),e.unwrap=()=>e._zod.def.innerType});function Ko(e){return new Go({type:"readonly",innerType:e})}const Qo=h("ZodCustom",(e,t)=>{As.init(e,t),V.init(e,t),e._zod.processJSONSchema=(n,s,a)=>za(e,n)});function Xo(e,t={}){return Na(Qo,e,t)}function ei(e){return Sa(e)}function ee(e){return ua(cr,e)}const ti=/^[A-Za-z0-9\-_+ ]*$/,ri=/^([A-Za-z0-9 ()/._-]|%\d*[CYmdHMSqvQtwhf$]|%\d*\{[a-z_]+\})*$/,ni=/^[A-Za-z0-9 ()/._-]*$/,si=/^[A-Za-z0-9 _+.@^~<>,-]*$/;function rt(e){return e.includes("..")||e.includes("~/")}const $t=ve().max(64,"Name must be 64 characters or less").regex(ti,"Name can only contain letters, numbers, hyphens, underscores, plus signs, and spaces"),Ee=ve().max(255,"Filename must be 255 characters or less").regex(ri,"Filename contains invalid characters. Use letters, numbers, underscores, hyphens, strftime codes (%Y, %m, %d), and Motion tokens (%{movienbr}, %v, etc.)").refine(e=>!rt(e),{message:"Filename cannot contain directory traversal sequences (.. or ~/)"}),zt=ve().max(4096,"Path must be 4096 characters or less").regex(ni,"Path contains invalid characters").refine(e=>!rt(e),{message:"Path cannot contain directory traversal sequences (.. or ~/)"});ve().max(255,"Email must be 255 characters or less").regex(si,"Email contains invalid characters");const Mt=ee().int("Framerate must be a whole number").min(1,"Framerate must be at least 1").max(100,"Framerate cannot exceed 100"),Ot=ee().int("Quality must be a whole number").min(1,"Quality must be at least 1%").max(100,"Quality cannot exceed 100%"),ai=ee().int("Width must be a whole number").min(160,"Width must be at least 160 pixels").max(4096,"Width cannot exceed 4096 pixels"),oi=ee().int("Height must be a whole number").min(120,"Height must be at least 120 pixels").max(2160,"Height cannot exceed 2160 pixels"),It=ee().int("Port must be a whole number").min(1,"Port must be at least 1").max(65535,"Port cannot exceed 65535"),ii=ee().int("Threshold must be a whole number").min(1,"Threshold must be at least 1").max(2147483647,"Threshold is too large"),ci=ee().int("Noise level must be a whole number").min(0,"Noise level must be at least 0").max(255,"Noise level cannot exceed 255"),li=ee().int("Log level must be a whole number").min(1,"Log level must be at least 1").max(9,"Log level cannot exceed 9"),ui=ee().int("Device ID must be a whole number").min(1,"Device ID must be at least 1").max(999,"Device ID cannot exceed 999"),di=ee().int("Must be a whole number").min(0,"Must be 0 or greater");ve().transform(e=>{const t=e.toLowerCase();return t==="on"||t==="true"||t==="1"});function mi(e,t){const s={device_name:$t,camera_name:$t,device_id:ui,target_dir:zt,snapshot_filename:Ee,picture_filename:Ee,movie_filename:Ee,log_file:zt,framerate:Mt,stream_maxrate:Mt,width:ai,height:oi,stream_quality:Ot,picture_quality:Ot,stream_port:It,webcontrol_port:It,threshold:ii,noise_level:ci,minimum_motion_frames:di,log_level:li}[e];if(!s)return typeof t=="string"&&rt(t)?{success:!1,error:"Value cannot contain directory traversal sequences (.. or ~/)"}:{success:!0};const a=s.safeParse(t);return a.success?{success:!0}:{success:!1,error:a.error.issues[0]?.message??"Invalid value"}}async function hi(){return await Pe("/0/api/system/reboot",{})}async function pi(){return await Pe("/0/api/system/shutdown",{})}async function fi(){return await Pe("/0/api/system/service-restart",{})}function gi({config:e,onChange:t,getError:n,originalConfig:s,systemStatus:a}){const{addToast:o}=Ye(),i=a?.actions?.service??!1,c=a?.actions?.power??!1,l=(g,w="")=>e[g]?.value??w,u=(g,w="")=>s?.[g]?.value??w,d=g=>e[g]?.password_set===!0,m=g=>s?.[g]?.password_set===!0,p=async()=>{if(window.confirm("Are you sure you want to reboot the Pi? The system will restart and be unavailable for about a minute."))try{await hi(),o("Rebooting... The system will be back online shortly.","info")}catch(g){o(g.message||"Failed to reboot. Power control may be disabled in config.","error")}},f=async()=>{if(window.confirm("Are you sure you want to shutdown the Pi? You will need to physically power it back on."))try{await pi(),o("Shutting down... The system will power off.","warning")}catch(g){o(g.message||"Failed to shutdown. Power control may be disabled in config.","error")}},y=async()=>{if(window.confirm("Are you sure you want to restart the Motion service? Active streams will be interrupted briefly."))try{await fi(),o("Restarting Motion... Streams will resume shortly.","info")}catch(g){o(g.message||"Failed to restart service. Service control may be disabled in config.","error")}};return r.jsxs(r.Fragment,{children:[r.jsx(O,{title:"Device Controls",description:"Service and system power management",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"flex flex-col gap-4",children:[r.jsxs("div",{children:[r.jsx("p",{className:"text-xs text-gray-400 mb-2",children:"Service Control"}),r.jsx("button",{onClick:y,disabled:!i,className:`px-4 py-2 rounded-lg text-sm transition-colors ${i?"bg-blue-600/20 text-blue-300 hover:bg-blue-600/30":"bg-gray-600/20 text-gray-500 cursor-not-allowed"}`,title:i?void 0:"Enable with webcontrol_actions service=on",children:"Restart Motion"}),!i&&r.jsxs("p",{className:"text-xs text-amber-500 mt-1",children:["Disabled - add ",r.jsx("code",{className:"text-xs bg-surface-base px-1 rounded",children:"webcontrol_actions service=on"})," to enable"]})]}),r.jsxs("div",{children:[r.jsx("p",{className:"text-xs text-gray-400 mb-2",children:"System Power"}),r.jsxs("div",{className:"flex gap-3",children:[r.jsx("button",{onClick:p,disabled:!c,className:`px-4 py-2 rounded-lg text-sm transition-colors ${c?"bg-yellow-600/20 text-yellow-300 hover:bg-yellow-600/30":"bg-gray-600/20 text-gray-500 cursor-not-allowed"}`,title:c?void 0:"Enable with webcontrol_actions power=on",children:"Restart Pi"}),r.jsx("button",{onClick:f,disabled:!c,className:`px-4 py-2 rounded-lg text-sm transition-colors ${c?"bg-red-600/20 text-red-300 hover:bg-red-600/30":"bg-gray-600/20 text-gray-500 cursor-not-allowed"}`,title:c?void 0:"Enable with webcontrol_actions power=on",children:"Shutdown Pi"})]}),!c&&r.jsxs("p",{className:"text-xs text-amber-500 mt-1",children:["Disabled - add ",r.jsx("code",{className:"text-xs bg-surface-base px-1 rounded",children:"webcontrol_actions power=on"})," to enable"]})]})]})}),r.jsxs(O,{title:"Authentication",description:"Web interface and stream access credentials",collapsible:!0,defaultOpen:!0,children:[r.jsxs("div",{className:"mb-6",children:[r.jsx("h4",{className:"font-medium text-sm mb-3 text-gray-300",children:"Web Interface"}),r.jsx("p",{className:"text-xs text-gray-400 mb-4",children:"Credentials for logging into this web interface. Format: username:password"}),u("webcontrol_authentication","")===""&&u("webcontrol_user_authentication","")===""&&r.jsx("div",{className:"mb-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg",children:r.jsxs("div",{className:"flex items-start gap-2",children:[r.jsx("svg",{className:"w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})}),r.jsxs("div",{className:"flex-1",children:[r.jsx("p",{className:"text-sm font-medium text-blue-300 mb-1",children:"Initial Setup Available"}),r.jsxs("p",{className:"text-xs text-blue-300/80",children:["Configure authentication now to secure your Motion installation. During initial setup, you can set credentials without changing"," ",r.jsx("code",{className:"text-xs bg-surface px-1 rounded",children:"webcontrol_parms"})," in the config file. Once authentication is configured, it will require restart to apply."]})]})]})}),r.jsxs("div",{className:"mb-4",children:[r.jsx("label",{className:"block text-sm font-medium mb-1 text-gray-300",children:"Admin Username"}),r.jsx("input",{type:"text",value:"admin",disabled:!0,className:`w-full px-3 py-2 bg-surface-elevated border border-gray-700 rounded-lg + text-gray-500 cursor-not-allowed`}),r.jsx("p",{className:"text-xs text-gray-500 mt-1",children:"Admin username is fixed for security"})]}),r.jsx(S,{label:"Admin Password",value:String(l("webcontrol_authentication","")).split(":")[1]||"",onChange:g=>{const w=String(l("webcontrol_authentication","")).split(":")[0]||"admin";t("webcontrol_authentication",`${w}:${g}`)},type:"password",placeholder:d("webcontrol_authentication")?"Password set (enter new to change)":"Enter password",helpText:d("webcontrol_authentication")?"Password is configured. Enter a new password to change it.":"Administrator password (click eye icon to reveal)",originalValue:m("webcontrol_authentication")?"[set]":""}),r.jsx(S,{label:"Viewer Username",value:String(l("webcontrol_user_authentication","")).split(":")[0]||"",onChange:g=>{const w=String(l("webcontrol_user_authentication","")).split(":")[1]||"";t("webcontrol_user_authentication",g?`${g}:${w}`:"")},helpText:"View-only username (can view streams but not change settings)",error:n?.("webcontrol_user_authentication"),originalValue:String(u("webcontrol_user_authentication","")).split(":")[0]||"",showVisibilityToggle:!1}),r.jsx(S,{label:"Viewer Password",value:String(l("webcontrol_user_authentication","")).split(":")[1]||"",onChange:g=>{const w=String(l("webcontrol_user_authentication","")).split(":")[0]||"";t("webcontrol_user_authentication",w?`${w}:${g}`:"")},type:"password",placeholder:d("webcontrol_user_authentication")?"Password set (enter new to change)":"Enter password",helpText:d("webcontrol_user_authentication")?"Password is configured. Enter a new password to change it.":"View-only password (click eye icon to reveal)",originalValue:m("webcontrol_user_authentication")?"[set]":""})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium text-sm mb-3 text-gray-300",children:"Direct Stream Access"}),r.jsx("p",{className:"text-xs text-gray-400 mb-4",children:"Authentication for direct stream URLs (embedded in websites, VLC, home automation)"}),r.jsx(R,{label:"Authentication Mode",value:String(l("webcontrol_auth_method","0")),onChange:g=>t("webcontrol_auth_method",g),options:[{value:"0",label:"None - No authentication required"},{value:"1",label:"Basic - Simple username/password (use with HTTPS)"},{value:"2",label:"Digest - Secure hash-based (recommended)"}],helpText:"Controls authentication for direct stream access and external API clients. The web UI uses session-based login instead.",error:n?.("webcontrol_auth_method")})]})]}),r.jsxs(O,{title:"Daemon",description:"Motion process settings",collapsible:!0,defaultOpen:!1,children:[r.jsx(Y,{label:"Run as Daemon",value:l("daemon",!1),onChange:g=>t("daemon",g),helpText:"Run Motion in background mode"}),r.jsx(S,{label:"PID File",value:String(l("pid_file","")),onChange:g=>t("pid_file",g),helpText:"Path to process ID file. Leave empty to let systemd manage the PID.",error:n?.("pid_file"),originalValue:String(u("pid_file",""))}),r.jsx(S,{label:"Log File",value:String(l("log_file","")),onChange:g=>t("log_file",g),helpText:"Path to log file. Leave empty to use journald (view with: journalctl -u motion).",error:n?.("log_file"),originalValue:String(u("log_file",""))}),r.jsx(R,{label:"Log Level",value:String(l("log_level","6")),onChange:g=>t("log_level",g),options:[{value:"1",label:"Emergency"},{value:"2",label:"Alert"},{value:"3",label:"Critical"},{value:"4",label:"Error"},{value:"5",label:"Warning"},{value:"6",label:"Notice"},{value:"7",label:"Info"},{value:"8",label:"Debug"},{value:"9",label:"All"}],helpText:"Verbosity level for logging",error:n?.("log_level")})]}),r.jsxs(O,{title:"Web Server",description:"API server configuration",collapsible:!0,defaultOpen:!1,children:[r.jsx(S,{label:"Port",value:String(l("webcontrol_port","8080")),onChange:g=>t("webcontrol_port",g),type:"number",helpText:"Primary web server port",error:n?.("webcontrol_port"),originalValue:String(u("webcontrol_port","8080"))}),r.jsx(Y,{label:"Localhost Only",value:l("webcontrol_localhost",!1),onChange:g=>t("webcontrol_localhost",g),helpText:"Restrict access to localhost only (127.0.0.1)"}),r.jsx(Y,{label:"TLS/HTTPS",value:l("webcontrol_tls",!1),onChange:g=>t("webcontrol_tls",g),helpText:"Enable HTTPS encryption"}),l("webcontrol_tls",!1)&&r.jsxs(r.Fragment,{children:[r.jsx(S,{label:"TLS Certificate",value:String(l("webcontrol_cert","")),onChange:g=>t("webcontrol_cert",g),helpText:"Path to TLS certificate file (.crt or .pem)",error:n?.("webcontrol_cert")}),r.jsx(S,{label:"TLS Private Key",value:String(l("webcontrol_key","")),onChange:g=>t("webcontrol_key",g),helpText:"Path to TLS private key file (.key or .pem)",error:n?.("webcontrol_key")})]})]})]})}function xi({config:e,onChange:t,getError:n}){const s=(m,p="")=>e[m]?.value??p,a=Number(s("width",640)),o=Number(s("height",480)),i=at(a,o),c=st.some(m=>m.width===a&&m.height===o),l=m=>{if(m==="custom")return;const{width:p,height:f}=Pr(m);t("width",p),t("height",f)},u=m=>{t("width",Number(m))},d=m=>{t("height",Number(m))};return r.jsxs(O,{title:"Device Settings",description:"Basic camera configuration and identification",collapsible:!0,defaultOpen:!1,children:[r.jsx(S,{label:"Camera Name",value:String(s("device_name","")),onChange:m=>t("device_name",m),placeholder:"My Camera",helpText:"Friendly name for this camera",error:n?.("device_name")}),r.jsx(R,{label:"Resolution",value:c?i:"custom",onChange:l,options:[...st.map(m=>({value:at(m.width,m.height),label:m.label})),{value:"custom",label:"Custom"}],helpText:"Video resolution (width x height)"}),!c&&r.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[r.jsx(S,{label:"Width",value:String(a),onChange:u,type:"number",helpText:"Custom width in pixels",error:n?.("width")}),r.jsx(S,{label:"Height",value:String(o),onChange:d,type:"number",helpText:"Custom height in pixels",error:n?.("height")})]}),r.jsx(P,{label:"Framerate",value:Number(s("framerate",15)),onChange:m=>t("framerate",m),min:2,max:30,unit:" fps",helpText:"Frames per second (higher uses more CPU)",error:n?.("framerate")}),r.jsx(R,{label:"Rotation",value:String(s("rotate",0)),onChange:m=>t("rotate",Number(m)),options:Tr.map(m=>({value:String(m.value),label:m.label})),helpText:"Rotate camera image"})]})}function bi({onClose:e,detectedCameras:t}){const[n,s]=x.useState("select"),[a,o]=x.useState(null),[i,c]=x.useState(!1),[l,u]=x.useState(""),[d,m]=x.useState(0),[p,f]=x.useState(0),[y,g]=x.useState(0),[w,_]=x.useState(""),[v,$]=x.useState(""),[B,Z]=x.useState(""),D=gr(),W=xr(),de=b=>{o(b),u(b.device_name),m(b.default_width),f(b.default_height),g(b.default_fps),s("configure")},T=()=>{c(!0),u("Network Camera"),m(1920),f(1080),g(15),s("configure")},C=async()=>{w&&W.mutate({url:w,user:v||void 0,pass:B||void 0,timeout:10},{onSuccess:b=>{b.status==="ok"&&s("complete")}})},z=()=>{const b={type:i?"netcam":a.type,device_id:i?w:a.device_id,device_path:i?w:a.device_path,device_name:l,sensor_model:a?.sensor_model,width:d,height:p,fps:y};D.mutate(b,{onSuccess:()=>{s("complete"),setTimeout(()=>{e()},2e3)}})};return r.jsx("div",{className:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4",children:r.jsxs("div",{className:"bg-background rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col",children:[r.jsxs("div",{className:"p-6 border-b border-secondary/20",children:[r.jsxs("div",{className:"flex items-center justify-between",children:[r.jsx("h2",{className:"text-xl font-semibold text-primary",children:"Add Camera"}),r.jsx("button",{onClick:e,className:"text-secondary hover:text-primary transition-colors",children:"✕"})]}),r.jsx("div",{className:"mt-4 flex items-center gap-2",children:["select","configure",i?"test":null,"complete"].filter(Boolean).map((b,F,Q)=>r.jsxs("div",{className:"flex items-center flex-1",children:[r.jsx("div",{className:`flex-1 h-1 rounded ${Q.indexOf(n)>=F?"bg-accent":"bg-secondary/20"}`}),F0&&r.jsx("div",{className:"space-y-2",children:t.map((b,F)=>r.jsx("button",{onClick:()=>de(b),className:"w-full p-4 text-left bg-secondary/10 rounded-lg border border-secondary/20 hover:border-accent transition-colors",children:r.jsxs("div",{className:"flex items-center gap-3",children:[r.jsx("div",{className:"text-2xl",children:b.type==="libcam"?"🎥":"📹"}),r.jsxs("div",{className:"flex-1",children:[r.jsx("div",{className:"font-medium text-primary",children:b.device_name}),r.jsxs("div",{className:"text-xs text-secondary",children:[b.sensor_model&&`${b.sensor_model} • `,b.default_width,"x",b.default_height," @ ",b.default_fps,"fps"]})]})]})},`${b.device_id}-${F}`))}),r.jsx("button",{onClick:T,className:"w-full p-4 text-left bg-secondary/10 rounded-lg border border-secondary/20 hover:border-accent transition-colors",children:r.jsxs("div",{className:"flex items-center gap-3",children:[r.jsx("div",{className:"text-2xl",children:"🌐"}),r.jsxs("div",{className:"flex-1",children:[r.jsx("div",{className:"font-medium text-primary",children:"Add Network Camera"}),r.jsx("div",{className:"text-xs text-secondary",children:"RTSP, HTTP, or other network camera URL"})]})]})}),t.length===0&&r.jsx("p",{className:"text-sm text-secondary text-center py-4",children:"No cameras detected. Add a network camera manually or connect a camera."})]}),n==="configure"&&r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{children:[r.jsx("h3",{className:"text-lg font-medium text-primary mb-2",children:"Configure Camera"}),r.jsx("p",{className:"text-sm text-secondary",children:"Set camera name and capture settings"})]}),r.jsxs("div",{className:"space-y-3",children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Camera Name"}),r.jsx("input",{type:"text",value:l,onChange:b=>u(b.target.value),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent",placeholder:"e.g., Front Door"})]}),i&&r.jsxs(r.Fragment,{children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Camera URL *"}),r.jsx("input",{type:"text",value:w,onChange:b=>_(b.target.value),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent font-mono text-sm",placeholder:"rtsp://192.168.1.100:554/stream"})]}),r.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Username (optional)"}),r.jsx("input",{type:"text",value:v,onChange:b=>$(b.target.value),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent",placeholder:"admin"})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Password (optional)"}),r.jsx("input",{type:"password",value:B,onChange:b=>Z(b.target.value),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent",placeholder:"••••••"})]})]})]}),r.jsxs("div",{className:"grid grid-cols-3 gap-3",children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Width"}),r.jsx("input",{type:"number",value:d,onChange:b=>m(parseInt(b.target.value)),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent"})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"Height"}),r.jsx("input",{type:"number",value:p,onChange:b=>f(parseInt(b.target.value)),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent"})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium text-primary mb-1",children:"FPS"}),r.jsx("input",{type:"number",value:y,onChange:b=>g(parseInt(b.target.value)),className:"w-full px-3 py-2 bg-secondary/10 border border-secondary/20 rounded-md text-primary focus:outline-none focus:border-accent"})]})]})]})]}),n==="test"&&i&&r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{children:[r.jsx("h3",{className:"text-lg font-medium text-primary mb-2",children:"Test Connection"}),r.jsx("p",{className:"text-sm text-secondary",children:"Verify that the camera URL is accessible"})]}),r.jsx("div",{className:"p-4 bg-secondary/10 rounded-lg",children:r.jsx("div",{className:"text-sm font-mono text-primary break-all",children:w})}),W.isError&&r.jsx("div",{className:"p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-600 dark:text-red-400",children:"Connection test failed. Check the URL and credentials."}),W.isSuccess&&W.data.status==="ok"&&r.jsx("div",{className:"p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-sm text-green-600 dark:text-green-400",children:"✓ Connection successful!"})]}),n==="complete"&&r.jsxs("div",{className:"text-center space-y-4 py-8",children:[r.jsx("div",{className:"text-6xl",children:"✓"}),r.jsx("h3",{className:"text-lg font-medium text-primary",children:"Camera Added!"}),r.jsxs("p",{className:"text-sm text-secondary",children:[l," has been added to your configuration."]})]})]}),n!=="complete"&&r.jsxs("div",{className:"p-6 border-t border-secondary/20 flex justify-between",children:[r.jsx("button",{onClick:()=>{n==="configure"?s("select"):n==="test"?s("configure"):e()},className:"px-4 py-2 text-sm bg-secondary/20 text-primary rounded-md hover:bg-secondary/30 transition-colors",children:"Back"}),r.jsx("button",{onClick:()=>{n==="configure"?i?s("test"):z():n==="test"&&C()},disabled:n==="configure"&&(!l||!d||!p||!y)||n==="configure"&&i&&!w||n==="test"&&W.isPending||D.isPending,className:"px-4 py-2 text-sm bg-accent text-white rounded-md hover:bg-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",children:n==="test"&&W.isPending?"Testing...":n==="test"?"Test Connection":D.isPending?"Adding...":n==="configure"&&!i?"Add Camera":"Next"})]})]})})}function vi({camera:e,onAdd:t}){const n={libcam:"Pi Camera (libcamera)",v4l2:"USB Camera (V4L2)",netcam:"Network Camera",unknown:"Unknown Camera"}[e.type],s={libcam:"🎥",v4l2:"📹",netcam:"🌐",unknown:"❓"}[e.type];return r.jsx("div",{className:"p-4 bg-secondary/10 rounded-lg border border-secondary/20 hover:border-accent/50 transition-colors",children:r.jsxs("div",{className:"flex items-start justify-between",children:[r.jsxs("div",{className:"flex items-start gap-3 flex-1",children:[r.jsx("div",{className:"text-2xl",children:s}),r.jsxs("div",{className:"flex-1 min-w-0",children:[r.jsxs("div",{className:"flex items-center gap-2",children:[r.jsx("h5",{className:"font-medium text-primary",children:e.device_name}),r.jsx("span",{className:"text-xs px-2 py-0.5 bg-accent/20 text-accent rounded",children:n})]}),r.jsxs("dl",{className:"mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs",children:[e.sensor_model&&r.jsxs(r.Fragment,{children:[r.jsx("dt",{className:"text-secondary",children:"Sensor:"}),r.jsx("dd",{className:"text-primary font-mono",children:e.sensor_model})]}),r.jsx("dt",{className:"text-secondary",children:"Device:"}),r.jsx("dd",{className:"text-primary font-mono truncate",title:e.device_path,children:e.device_path}),r.jsx("dt",{className:"text-secondary",children:"Default:"}),r.jsxs("dd",{className:"text-primary",children:[e.default_width,"x",e.default_height," @ ",e.default_fps,"fps"]})]}),e.resolutions.length>0&&r.jsxs("details",{className:"mt-2",children:[r.jsxs("summary",{className:"text-xs text-secondary cursor-pointer hover:text-primary",children:["Available resolutions (",e.resolutions.length,")"]}),r.jsxs("div",{className:"mt-1 flex flex-wrap gap-1",children:[e.resolutions.slice(0,10).map(([a,o],i)=>r.jsxs("span",{className:"text-xs px-1.5 py-0.5 bg-secondary/10 rounded",children:[a,"x",o]},i)),e.resolutions.length>10&&r.jsxs("span",{className:"text-xs text-secondary",children:["+",e.resolutions.length-10," more"]})]})]})]})]}),r.jsx("button",{onClick:t,className:"ml-4 px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/90 transition-colors whitespace-nowrap",children:"Add Camera"})]})})}function yi({camera:e}){const[t,n]=x.useState(!1),s=br(),a=()=>{s.mutate({camId:e.id},{onSuccess:()=>{n(!1)}})};return r.jsx("div",{className:"p-4 bg-secondary/10 rounded-lg border border-secondary/20",children:r.jsxs("div",{className:"flex items-start justify-between",children:[r.jsxs("div",{className:"flex items-start gap-3 flex-1",children:[r.jsx("div",{className:"text-2xl",children:"✅"}),r.jsxs("div",{className:"flex-1 min-w-0",children:[r.jsxs("div",{className:"flex items-center gap-2",children:[r.jsx("h5",{className:"font-medium text-primary",children:e.name}),r.jsx("span",{className:"text-xs px-2 py-0.5 bg-green-500/20 text-green-600 dark:text-green-400 rounded",children:"Active"})]}),r.jsxs("dl",{className:"mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs",children:[r.jsx("dt",{className:"text-secondary",children:"Camera ID:"}),r.jsx("dd",{className:"text-primary font-mono",children:e.id}),e.width&&e.height&&r.jsxs(r.Fragment,{children:[r.jsx("dt",{className:"text-secondary",children:"Resolution:"}),r.jsxs("dd",{className:"text-primary",children:[e.width,"x",e.height]})]}),r.jsx("dt",{className:"text-secondary",children:"Stream URL:"}),r.jsx("dd",{className:"text-primary font-mono truncate",children:r.jsx("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",className:"hover:text-accent",children:e.url})})]})]})]}),r.jsxs("div",{className:"ml-4 flex gap-2",children:[r.jsx("a",{href:`/camera/${e.id}`,className:"px-3 py-1.5 text-sm bg-secondary/20 text-primary rounded hover:bg-secondary/30 transition-colors",children:"View"}),t?r.jsxs("div",{className:"flex gap-1",children:[r.jsx("button",{onClick:a,disabled:s.isPending,className:"px-3 py-1.5 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50",children:s.isPending?"Removing...":"Confirm"}),r.jsx("button",{onClick:()=>n(!1),className:"px-3 py-1.5 text-sm bg-secondary/20 text-primary rounded hover:bg-secondary/30 transition-colors",children:"Cancel"})]}):r.jsx("button",{onClick:()=>n(!0),className:"px-3 py-1.5 text-sm bg-red-500/20 text-red-600 dark:text-red-400 rounded hover:bg-red-500/30 transition-colors",children:"Remove"})]})]})})}function _i(){const[e,t]=x.useState(!1),{data:n=[],isLoading:s}=vr(),{data:a,isLoading:o}=yr(),{data:i,isLoading:c,refetch:l}=_r(),u=i?.cameras||[],d=u.length>0;return s||o?r.jsx("div",{className:"p-6 bg-secondary/20 rounded-lg",children:r.jsx("p",{className:"text-secondary",children:"Loading camera information..."})}):r.jsxs("div",{className:"space-y-6",children:[r.jsxs("div",{className:"flex items-center justify-between",children:[r.jsxs("div",{children:[r.jsx("h3",{className:"text-lg font-semibold text-primary",children:"Camera Management"}),r.jsxs("p",{className:"text-sm text-secondary",children:["Add, remove, and configure cameras",a?.is_raspberry_pi&&` • ${a.pi_model}`]})]}),r.jsx("button",{onClick:()=>{l(),t(!0)},className:"px-4 py-2 bg-accent text-white rounded-md hover:bg-accent/90 transition-colors",children:"Add Camera"})]}),n.length===0&&r.jsxs("div",{className:"p-8 bg-primary/10 rounded-lg text-center space-y-4",children:[r.jsx("div",{className:"text-4xl",children:"📷"}),r.jsx("h3",{className:"text-xl font-semibold text-primary",children:"Welcome to Motion"}),r.jsx("p",{className:"text-secondary max-w-md mx-auto",children:"Get started by adding your first camera. Motion will automatically detect connected cameras or you can manually configure a network camera."}),r.jsx("button",{onClick:()=>{l(),t(!0)},className:"px-6 py-3 bg-accent text-white rounded-md hover:bg-accent/90 transition-colors font-medium",children:"Add Your First Camera"})]}),n.length>0&&r.jsxs("div",{className:"space-y-3",children:[r.jsx("h4",{className:"text-sm font-medium text-secondary",children:"Configured Cameras"}),r.jsx("div",{className:"grid gap-3",children:n.map(m=>r.jsx(yi,{camera:m},m.id))})]}),!c&&d&&r.jsxs("div",{className:"space-y-3",children:[r.jsxs("h4",{className:"text-sm font-medium text-secondary",children:["Available Cameras (",u.length,")"]}),r.jsx("p",{className:"text-xs text-secondary",children:"These cameras were detected but not yet configured"}),r.jsx("div",{className:"grid gap-3",children:u.map((m,p)=>r.jsx(vi,{camera:m,onAdd:()=>t(!0)},`${m.device_id}-${p}`))})]}),e&&r.jsx(bi,{onClose:()=>t(!1),detectedCameras:u,platformInfo:a})]})}function lr(e){const{data:t,isLoading:n}=Ft();return x.useMemo(()=>{if(n||!t?.status)return{isLoading:!0,cameraType:"unknown",cameraDevice:"",isConnected:!1,features:{hasLibcamControls:!1,hasV4L2Controls:!1,hasNetcamConfig:!1,hasDualStream:!1,supportsPassthrough:!1}};const s=`cam${e}`,a=t.status[s];if(!a)return{isLoading:!1,cameraType:"unknown",cameraDevice:"",isConnected:!1,features:{hasLibcamControls:!1,hasV4L2Controls:!1,hasNetcamConfig:!1,hasDualStream:!1,supportsPassthrough:!1}};const o=a.camera_type??"unknown";return{isLoading:!1,cameraType:o,cameraDevice:a.camera_device??"",isConnected:!a.lost_connection,features:{hasLibcamControls:o==="libcam",hasV4L2Controls:o==="v4l2",hasNetcamConfig:o==="netcam",hasDualStream:o==="netcam"&&a.has_high_stream===!0,supportsPassthrough:o==="netcam"},...o==="libcam"&&{libcamCapabilities:a.supportedControls},...o==="v4l2"&&{v4l2Controls:a.v4l2_controls},...o==="netcam"&&{netcamStatus:a.netcam_status}}},[t,e,n])}function ji({cameraId:e}){const{cameraType:t,cameraDevice:n,isConnected:s}=lr(e);return r.jsx(O,{title:"Camera Source",description:"Camera connection and type information",collapsible:!0,defaultOpen:!0,children:r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{className:"flex items-center gap-3",children:[r.jsx("span",{className:"text-sm text-gray-400",children:"Type:"}),r.jsx(wi,{type:t})]}),r.jsxs("div",{className:"flex items-start gap-3",children:[r.jsx("span",{className:"text-sm text-gray-400 pt-0.5",children:"Device:"}),n?r.jsx("div",{className:"flex-1",children:r.jsx("code",{className:"text-sm text-gray-200 bg-gray-800 px-2 py-1 rounded",children:n})}):r.jsx("span",{className:"text-sm text-gray-500 italic",children:"Not configured"})]}),r.jsxs("div",{className:"flex items-center gap-3",children:[r.jsx("span",{className:"text-sm text-gray-400",children:"Status:"}),r.jsx(Ni,{isConnected:s})]}),t==="unknown"&&r.jsxs("div",{className:"mt-4 p-4 bg-gray-800 border border-gray-700 rounded",children:[r.jsx("p",{className:"text-sm text-gray-300 mb-2",children:r.jsx("strong",{children:"No camera configured"})}),r.jsx("p",{className:"text-sm text-gray-400 mb-3",children:"Configure a camera by setting one of the following:"}),r.jsxs("ul",{className:"text-sm text-gray-400 list-disc list-inside space-y-1 ml-2",children:[r.jsxs("li",{children:[r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"libcam_device"})," - Raspberry Pi camera"]}),r.jsxs("li",{children:[r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"v4l2_device"})," - USB webcam (e.g., /dev/video0)"]}),r.jsxs("li",{children:[r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"netcam_url"})," - IP camera (RTSP/HTTP URL)"]})]})]})]})})}function wi({type:e}){const t={libcam:"bg-purple-500/20 text-purple-300 border-purple-500/30",v4l2:"bg-blue-500/20 text-blue-300 border-blue-500/30",netcam:"bg-green-500/20 text-green-300 border-green-500/30",unknown:"bg-gray-500/20 text-gray-400 border-gray-500/30"},n={libcam:"libcamera (Pi Camera)",v4l2:"V4L2 (USB Camera)",netcam:"Network Camera (IP)",unknown:"Not Configured"};return r.jsx("span",{className:`inline-flex items-center px-3 py-1 rounded border text-xs font-medium ${t[e]}`,children:n[e]})}function Ni({isConnected:e}){return r.jsxs("span",{className:`inline-flex items-center px-3 py-1 rounded border text-xs font-medium ${e?"bg-green-500/20 text-green-300 border-green-500/30":"bg-red-500/20 text-red-300 border-red-500/30"}`,children:[r.jsx("span",{className:`w-2 h-2 rounded-full mr-2 ${e?"bg-green-400":"bg-red-400"}`}),e?"Connected":"Disconnected"]})}function Si({config:e,onChange:t,getError:n,capabilities:s,originalConfig:a}){const o=(l,u="")=>e[l]?.value??u,i=(l,u="")=>a?.[l]?.value??u,c=!!o("libcam_awb_enable",!1);return r.jsxs(O,{title:"libcamera Controls",description:"Raspberry Pi camera controls (libcamera only)",collapsible:!0,defaultOpen:!1,children:[r.jsx(P,{label:"Brightness",value:Number(o("libcam_brightness",0)),onChange:l=>t("libcam_brightness",l),min:-1,max:1,step:.1,helpText:"Brightness adjustment (-1.0 to 1.0)",error:n?.("libcam_brightness")}),r.jsx(P,{label:"Contrast",value:Number(o("libcam_contrast",1)),onChange:l=>t("libcam_contrast",l),min:0,max:32,step:.5,helpText:"Contrast adjustment (0.0 to 32.0)",error:n?.("libcam_contrast")}),r.jsx(P,{label:"Gain (ISO)",value:Number(o("libcam_gain",1)),onChange:l=>t("libcam_gain",l),min:0,max:10,step:.1,helpText:"Analog gain (0=auto, 1.0-10.0) (Gain 1.0 ~ ISO 100)",error:n?.("libcam_gain")}),r.jsx(Y,{label:"Auto White Balance",value:c,onChange:l=>t("libcam_awb_enable",l),helpText:"Enable automatic white balance"}),c&&r.jsxs(r.Fragment,{children:[r.jsx(R,{label:"AWB Mode",value:String(o("libcam_awb_mode",0)),onChange:l=>t("libcam_awb_mode",Number(l)),options:$r.map(l=>({value:String(l.value),label:l.label})),helpText:"White balance mode"}),s?.AwbLocked!==!1&&r.jsx(Y,{label:"Lock AWB",value:!!o("libcam_awb_locked",!1),onChange:l=>t("libcam_awb_locked",l),helpText:"Lock white balance settings"})]}),!c&&r.jsxs(r.Fragment,{children:[s?.ColourTemperature!==!1&&r.jsx(P,{label:"Color Temperature",value:Number(o("libcam_colour_temp",0)),onChange:l=>t("libcam_colour_temp",l),min:0,max:1e4,step:100,unit:" K",helpText:"Manual color temperature in Kelvin (0-10000)",error:n?.("libcam_colour_temp")}),r.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[r.jsx(P,{label:"Red Gain",value:Number(o("libcam_colour_gain_r",1)),onChange:l=>t("libcam_colour_gain_r",l),min:0,max:8,step:.1,helpText:"Red color gain (0.0-8.0)",error:n?.("libcam_colour_gain_r")}),r.jsx(P,{label:"Blue Gain",value:Number(o("libcam_colour_gain_b",1)),onChange:l=>t("libcam_colour_gain_b",l),min:0,max:8,step:.1,helpText:"Blue color gain (0.0-8.0)",error:n?.("libcam_colour_gain_b")})]}),s?.ColourTemperature===!1&&r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded",children:[r.jsx("strong",{children:"Note:"})," Color Temperature control is not available on this camera. NoIR cameras and some other sensors don't support this feature. Use Red/Blue Gain for manual white balance."]})]}),s?.AfMode?r.jsxs(r.Fragment,{children:[r.jsx(R,{label:"Autofocus Mode",value:String(o("libcam_af_mode",0)),onChange:l=>t("libcam_af_mode",Number(l)),options:zr.map(l=>({value:String(l.value),label:l.label})),helpText:"Focus control mode"}),Number(o("libcam_af_mode",0))===0&&s?.LensPosition&&r.jsx(P,{label:"Lens Position",value:Number(o("libcam_lens_position",0)),onChange:l=>t("libcam_lens_position",l),min:0,max:15,step:.5,unit:" dioptres",helpText:"Manual focus position (0.0-15.0 dioptres)",error:n?.("libcam_lens_position")}),Number(o("libcam_af_mode",0))>0&&r.jsxs(r.Fragment,{children:[s?.AfRange&&r.jsx(R,{label:"Autofocus Range",value:String(o("libcam_af_range",0)),onChange:l=>t("libcam_af_range",Number(l)),options:Mr.map(l=>({value:String(l.value),label:l.label})),helpText:"Focus range preference"}),s?.AfSpeed&&r.jsx(R,{label:"Autofocus Speed",value:String(o("libcam_af_speed",0)),onChange:l=>t("libcam_af_speed",Number(l)),options:Or.map(l=>({value:String(l.value),label:l.label})),helpText:"Focus adjustment speed"})]})]}):s!==void 0?r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded",children:[r.jsx("strong",{children:"Autofocus:"})," Not supported on this camera.",s?.LensPosition?r.jsx("span",{children:" Manual focus (lens position) is available."}):r.jsx("span",{children:" This camera has fixed focus."})]}):null,!s?.AfMode&&s?.LensPosition&&r.jsx(P,{label:"Lens Position",value:Number(o("libcam_lens_position",0)),onChange:l=>t("libcam_lens_position",l),min:0,max:15,step:.5,unit:" dioptres",helpText:"Manual focus position (0.0-15.0 dioptres)",error:n?.("libcam_lens_position")}),r.jsx(S,{label:"Buffer Count",value:String(o("libcam_buffer_count",4)),onChange:l=>t("libcam_buffer_count",Number(l)),type:"number",helpText:"Frame buffers for capture (2-8). Higher values reduce frame drops under load but use more memory. Default 4 works for most setups; increase to 6-8 if seeing drops at high framerates.",error:n?.("libcam_buffer_count"),originalValue:String(i("libcam_buffer_count",4))})]})}function Ci({config:e,onChange:t,controls:n,getError:s}){if(!n||n.length===0)return r.jsx(O,{title:"Camera Controls",description:"USB camera settings",collapsible:!0,defaultOpen:!1,children:r.jsx("p",{className:"text-gray-400 text-sm",children:"No controls available for this camera."})});const a=Pi(n);return r.jsx(O,{title:"Camera Controls",description:"USB camera settings (V4L2)",collapsible:!0,defaultOpen:!1,children:Object.entries(a).map(([o,i])=>r.jsxs("div",{className:"space-y-4",children:[o!=="Other"&&r.jsx("h4",{className:"text-sm font-medium text-gray-300 mt-4 first:mt-0",children:o}),i.map(c=>r.jsx(ki,{control:c,value:Ti(e,c),onChange:l=>t(`v4l2_${c.id}`,l),error:s?.(`v4l2_${c.id}`)},c.id))]},o))})}function ki({control:e,value:t,onChange:n,error:s}){switch(e.type){case"boolean":return r.jsx(Y,{label:e.name,value:!!t,onChange:n,helpText:`Range: ${e.min}-${e.max}, Default: ${e.default}`});case"menu":return r.jsx(R,{label:e.name,value:String(t),onChange:a=>n(Number(a)),options:e.menuItems?.map(a=>({value:String(a.value),label:a.label}))??[],helpText:`Default: ${e.default}`,error:s});default:return r.jsx(P,{label:e.name,value:Number(t),onChange:n,min:e.min,max:e.max,step:e.step??1,helpText:`Range: ${e.min}-${e.max}, Default: ${e.default}`,error:s})}}function Ti(e,t){const n=`v4l2_${t.id}`,s=e[n]?.value;return s!==void 0?t.type==="boolean"?!!s:Number(s):t.type==="boolean"?!!t.current:t.current}function Pi(e){const t={"Image Quality":[],"Exposure & Gain":[],"White Balance":[],Focus:[],Other:[]},n={"Image Quality":["brightness","contrast","saturation","hue","sharpness","gamma"],"Exposure & Gain":["exposure","gain","iso","backlight"],"White Balance":["white","balance","color","colour","temperature"],Focus:["focus","zoom","pan","tilt","lens"]};for(const s of e){const a=s.name.toLowerCase();let o=!1;for(const[i,c]of Object.entries(n))if(c.some(l=>a.includes(l))){t[i].push(s),o=!0;break}o||t.Other.push(s)}return Object.fromEntries(Object.entries(t).filter(([s,a])=>a.length>0))}function $i({config:e,onChange:t,connectionStatus:n,hasDualStream:s,getError:a}){const o=(i,c="")=>e[i]?.value??c;return r.jsx(O,{title:"Network Camera",description:"IP camera connection settings (RTSP/HTTP)",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[n&&r.jsxs("div",{className:"flex items-center gap-3 pb-2",children:[r.jsx("span",{className:"text-sm text-gray-400",children:"Connection:"}),r.jsx(zi,{status:n})]}),r.jsx(S,{label:"Stream URL",value:String(o("netcam_url","")),onChange:i=>t("netcam_url",i),placeholder:"rtsp://192.168.1.100:554/stream",helpText:"RTSP, HTTP, HTTPS, or file:// URL for the camera stream",error:a?.("netcam_url")}),r.jsx(S,{label:"Credentials",value:String(o("netcam_userpass","")),onChange:i=>t("netcam_userpass",i),type:"password",placeholder:"username:password",helpText:"Leave empty if camera doesn't require authentication",error:a?.("netcam_userpass")}),r.jsx(S,{label:"FFmpeg Parameters",value:String(o("netcam_params","")),onChange:i=>t("netcam_params",i),placeholder:"-rtsp_transport tcp",helpText:"Advanced: Custom FFmpeg input options (e.g., -rtsp_transport tcp)",error:a?.("netcam_params")}),s&&r.jsxs(r.Fragment,{children:[r.jsxs("div",{className:"border-t border-gray-700 my-4 pt-4",children:[r.jsx("h4",{className:"text-sm font-medium text-gray-300 mb-3",children:"High Resolution Stream"}),r.jsx("p",{className:"text-xs text-gray-400 mb-4",children:"Optional secondary stream for higher quality recordings while using lower resolution for motion detection."})]}),r.jsx(S,{label:"High-Res URL",value:String(o("netcam_high_url","")),onChange:i=>t("netcam_high_url",i),placeholder:"rtsp://192.168.1.100:554/stream1",helpText:"Optional: Higher resolution stream for recordings",error:a?.("netcam_high_url")}),r.jsx(S,{label:"High-Res FFmpeg Parameters",value:String(o("netcam_high_params","")),onChange:i=>t("netcam_high_params",i),placeholder:"-rtsp_transport tcp",helpText:"FFmpeg parameters for high-resolution stream",error:a?.("netcam_high_params")})]}),r.jsxs("div",{className:"mt-4 p-4 bg-gray-800 border border-gray-700 rounded",children:[r.jsx("p",{className:"text-sm text-gray-300 mb-2",children:r.jsx("strong",{children:"Supported Protocols"})}),r.jsxs("ul",{className:"text-sm text-gray-400 list-disc list-inside space-y-1 ml-2",children:[r.jsxs("li",{children:[r.jsx("strong",{children:"RTSP:"})," ",r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"rtsp://"})," - Most IP cameras"]}),r.jsxs("li",{children:[r.jsx("strong",{children:"HTTP:"})," ",r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"http://"})," - MJPEG streams"]}),r.jsxs("li",{children:[r.jsx("strong",{children:"HTTPS:"})," ",r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"https://"})," - Secure streams"]}),r.jsxs("li",{children:[r.jsx("strong",{children:"File:"})," ",r.jsx("code",{className:"text-xs bg-gray-900 px-1 py-0.5 rounded",children:"file://"})," - Local video files"]})]})]})]})})}function zi({status:e}){const t={connected:{color:"bg-green-500/20 text-green-300 border-green-500/30",text:"Connected"},reading:{color:"bg-blue-500/20 text-blue-300 border-blue-500/30",text:"Reading"},not_connected:{color:"bg-red-500/20 text-red-300 border-red-500/30",text:"Not Connected"},reconnecting:{color:"bg-yellow-500/20 text-yellow-300 border-yellow-500/30",text:"Reconnecting"},unknown:{color:"bg-gray-500/20 text-gray-400 border-gray-500/30",text:"Unknown"}},{color:n,text:s}=t[e]||t.unknown;return r.jsxs("span",{className:`inline-flex items-center px-3 py-1 rounded border text-xs font-medium ${n}`,children:[r.jsx("span",{className:`w-2 h-2 rounded-full mr-2 ${e==="connected"?"bg-green-400":e==="reading"?"bg-blue-400 animate-pulse":e==="reconnecting"?"bg-yellow-400 animate-pulse":"bg-red-400"}`}),s]})}function Mi({config:e,onChange:t,getError:n}){const s=(_,v="")=>e[_]?.value??v,a=String(s("text_left","")),o=String(s("text_right","")),i=ot(a),c=ot(o),[l,u]=x.useState(i==="custom"?a:""),[d,m]=x.useState(c==="custom"?o:""),p=_=>{_==="custom"?t("text_left",l):t("text_left",it(_))},f=_=>{_==="custom"?t("text_right",d):t("text_right",it(_))},y=_=>{u(_),t("text_left",_)},g=_=>{m(_),t("text_right",_)},w=[{value:"disabled",label:"Disabled"},{value:"camera-name",label:"Camera Name"},{value:"timestamp",label:"Timestamp"},{value:"custom",label:"Custom Text"}];return r.jsxs(O,{title:"Text Overlay",description:"Add text overlays to video frames",collapsible:!0,defaultOpen:!1,children:[r.jsx(R,{label:"Left Text",value:i,onChange:p,options:w,helpText:"Text displayed in top-left corner"}),i==="custom"&&r.jsx(S,{label:"Custom Left Text",value:l,onChange:y,placeholder:"Enter custom text",helpText:"Use Motion format codes: %Y (year), %m (month), %d (day), %H (hour), %M (minute), %S (second), %$ (camera name)",error:n?.("text_left")}),r.jsx(R,{label:"Right Text",value:c,onChange:f,options:w,helpText:"Text displayed in top-right corner"}),c==="custom"&&r.jsx(S,{label:"Custom Right Text",value:d,onChange:g,placeholder:"Enter custom text",helpText:"Use Motion format codes: %Y (year), %m (month), %d (day), %H (hour), %M (minute), %S (second), %$ (camera name)",error:n?.("text_right")}),r.jsx(P,{label:"Text Scale",value:Number(s("text_scale",1)),onChange:_=>t("text_scale",_),min:1,max:10,unit:"x",helpText:"Text size multiplier (1-10)",error:n?.("text_scale")})]})}const Oi=[{value:"100",label:"Full (100%)"},{value:"75",label:"High (75%)"},{value:"50",label:"Medium (50%)"},{value:"25",label:"Low (25%)"},{value:"10",label:"Minimal (10%)"}];function Ii({config:e,onChange:t,getError:n}){const s=(o,i="")=>e[o]?.value??i,a=!s("stream_localhost",!1);return r.jsxs(O,{title:"Video Streaming",description:"Live MJPEG stream configuration",collapsible:!0,defaultOpen:!1,children:[r.jsx(Y,{label:"Enable Video Streaming",value:a,onChange:o=>t("stream_localhost",!o),helpText:"Enable/disable live MJPEG streaming. When disabled, stream is only accessible from localhost."}),a&&r.jsxs(r.Fragment,{children:[r.jsx(R,{label:"Streaming Resolution",value:String(s("stream_preview_scale",100)),onChange:o=>t("stream_preview_scale",Number(o)),options:Oi,helpText:"Scale stream as percentage of source resolution. Lower = less bandwidth and CPU."}),r.jsx(P,{label:"Stream Quality",value:Number(s("stream_quality",50)),onChange:o=>t("stream_quality",o),min:1,max:100,unit:"%",helpText:"JPEG compression quality (1-100). Higher = better quality, more bandwidth.",error:n?.("stream_quality")}),r.jsx(P,{label:"Stream Max Framerate",value:Number(s("stream_maxrate",15)),onChange:o=>t("stream_maxrate",o),min:1,max:30,unit:" fps",helpText:"Maximum frames per second (lower = less bandwidth and CPU)",error:n?.("stream_maxrate")}),r.jsx(Y,{label:"Show Motion Boxes",value:!!s("stream_motion",!1),onChange:o=>t("stream_motion",o),helpText:"Display motion detection boxes in stream"}),r.jsx(R,{label:"Direct Stream Access Security",value:String(s("webcontrol_auth_method",0)),onChange:o=>t("webcontrol_auth_method",Number(o)),options:Ir.map(o=>({value:String(o.value),label:o.label})),helpText:"Authentication when streams are accessed directly (embedded in other websites, VLC, home automation). None = open access on trusted networks only. Basic = use with HTTPS. Digest = recommended."}),r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded mt-4",children:[r.jsxs("p",{children:[r.jsx("strong",{children:"Stream URL:"})," ",r.jsx("code",{children:"http://[hostname]:[port]/[cam]/mjpg/stream"})]}),r.jsxs("p",{className:"mt-1",children:[r.jsx("strong",{children:"Note:"})," Streaming resolution scales the output to reduce bandwidth and CPU usage. Server-side resizing is always performed by Motion."]})]})]})]})}function Ai({config:e,onChange:t,getError:n}){const s=(d,m="")=>e[d]?.value??m,a=Number(s("width",640)),o=Number(s("height",480)),i=Number(s("threshold",1500)),c=Ar(i,a,o),l=d=>{const m=Number(d),p=Er(m,a,o);t("threshold",p)},u=[{value:"",label:"Off"},{value:"EedDl",label:"Light"},{value:"EedDl",label:"Medium (default)"},{value:"EedDl",label:"Heavy"}];return r.jsx(O,{title:"Motion Detection",description:"Configure motion detection sensitivity and behavior",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsx(P,{label:"Threshold",value:c,onChange:d=>l(String(d)),min:0,max:20,step:.1,unit:"%",helpText:`Percentage of frame that must change (${i} pixels at ${a}x${o}). Higher = less sensitive.`,error:n?.("threshold")}),r.jsx(S,{label:"Threshold Maximum",value:String(s("threshold_maximum",0)),onChange:d=>t("threshold_maximum",Number(d)),type:"number",helpText:"Maximum threshold for auto-tuning (0 = disabled)",error:n?.("threshold_maximum")}),r.jsx(Y,{label:"Auto-tune Threshold",value:s("threshold_tune",!1),onChange:d=>t("threshold_tune",d),helpText:"Automatically adjust threshold based on noise levels"}),r.jsx(Y,{label:"Auto-tune Noise Level",value:s("noise_tune",!1),onChange:d=>t("noise_tune",d),helpText:"Automatically determine optimal noise level"}),r.jsx(P,{label:"Noise Level",value:Number(s("noise_level",32)),onChange:d=>t("noise_level",d),min:1,max:255,helpText:"Noise tolerance (1-255). Lower values detect smaller motions.",error:n?.("noise_level")}),r.jsx(P,{label:"Light Switch Detection",value:Number(s("lightswitch_percent",0)),onChange:d=>t("lightswitch_percent",d),min:0,max:100,unit:"%",helpText:"Ignore sudden brightness changes (0 = disabled). Prevents false triggers from lights turning on/off.",error:n?.("lightswitch_percent")}),r.jsx(R,{label:"Despeckle Filter",value:String(s("despeckle_filter","")),onChange:d=>t("despeckle_filter",d),options:u,helpText:"Remove noise speckles from motion detection"}),r.jsx(P,{label:"Smart Mask Speed",value:Number(s("smart_mask_speed",0)),onChange:d=>t("smart_mask_speed",d),min:0,max:10,helpText:"Auto-mask static areas (0 = disabled, 1-10 = speed). Higher values adapt faster to static objects.",error:n?.("smart_mask_speed")}),r.jsx(R,{label:"Locate Motion Mode",value:String(s("locate_motion_mode","off")),onChange:d=>t("locate_motion_mode",d),options:Dr,helpText:"Draw box around motion area. 'Preview' = stream only, 'On' = saved images, 'Both' = both."}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"Event Timing"}),r.jsx(S,{label:"Event Gap (seconds)",value:String(s("event_gap",60)),onChange:d=>t("event_gap",Number(d)),type:"number",min:"0",helpText:"Seconds of no motion before ending an event. Prevents splitting continuous motion into multiple events.",error:n?.("event_gap")}),r.jsx(S,{label:"Pre-Capture (frames)",value:String(s("pre_capture",0)),onChange:d=>t("pre_capture",Number(d)),type:"number",min:"0",helpText:"Frames to capture before motion detected. Uses CPU/memory to buffer frames.",error:n?.("pre_capture")}),r.jsx(S,{label:"Post-Capture (frames)",value:String(s("post_capture",0)),onChange:d=>t("post_capture",Number(d)),type:"number",min:"0",helpText:"Frames to capture after motion stops",error:n?.("post_capture")}),r.jsx(S,{label:"Minimum Motion Frames",value:String(s("minimum_motion_frames",1)),onChange:d=>t("minimum_motion_frames",Number(d)),type:"number",min:"1",helpText:"Consecutive frames with motion required to trigger event. Filters brief false positives.",error:n?.("minimum_motion_frames")})]})]})})}function Di({config:e,onChange:t,getError:n}){const s=(p,f="")=>e[p]?.value??f,a=String(s("picture_output","off")),o=Number(s("snapshot_interval",0)),i=Rr(a,o),[c,l]=x.useState(i),u=p=>{l(p);const f=Fr(p);t("picture_output",f.picture_output),f.snapshot_interval!==void 0&&t("snapshot_interval",f.snapshot_interval)},d=[{value:"off",label:"Off"},{value:"motion-triggered",label:"Motion Triggered (all frames)"},{value:"motion-triggered-one",label:"Motion Triggered (first frame only)"},{value:"best",label:"Best Quality Frame"},{value:"center",label:"Center Frame"},{value:"interval-snapshots",label:"Interval Snapshots"},{value:"manual",label:"Manual Only"}],m=["%Y - Year (4 digits)","%m - Month (01-12)","%d - Day (01-31)","%H - Hour (00-23)","%M - Minute (00-59)","%S - Second (00-59)","%q - Frame number","%v - Event number","%$ - Camera name"].join(", ");return r.jsx(O,{title:"Picture Settings",description:"Configure picture capture and snapshots",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsx(R,{label:"Capture Mode",value:c,onChange:u,options:d,helpText:"When to capture still images during motion events"}),r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded",children:[r.jsx("strong",{children:"Current settings:"})," picture_output=",a,o>0&&`, snapshot_interval=${o}s`]}),c==="motion-triggered"&&r.jsxs(r.Fragment,{children:[r.jsxs("div",{className:"text-xs text-yellow-300 bg-yellow-900/30 border border-yellow-700 p-3 rounded",children:[r.jsx("strong",{children:"Warning:"})," This mode captures every frame during motion. At 15fps, continuous motion can generate 900+ pictures per minute. Configure limits below to prevent runaway capture."]}),r.jsx(S,{label:"Max Pictures Per Event",value:String(s("picture_max_per_event",0)),onChange:p=>t("picture_max_per_event",Number(p)),type:"number",min:"0",max:"100000",helpText:"Maximum pictures per motion event (0 = unlimited)",error:n?.("picture_max_per_event")}),r.jsx(S,{label:"Min Interval Between Pictures (ms)",value:String(s("picture_min_interval",0)),onChange:p=>t("picture_min_interval",Number(p)),type:"number",min:"0",max:"60000",helpText:"Minimum milliseconds between captures (0 = no limit). 1000ms = 1 picture/second.",error:n?.("picture_min_interval")})]}),c==="interval-snapshots"&&r.jsx(S,{label:"Snapshot Interval (seconds)",value:String(s("snapshot_interval",60)),onChange:p=>t("snapshot_interval",Number(p)),type:"number",min:"1",helpText:"Seconds between snapshots (independent of motion)",error:n?.("snapshot_interval")}),r.jsx(P,{label:"Picture Quality",value:Number(s("picture_quality",75)),onChange:p=>t("picture_quality",p),min:1,max:100,unit:"%",helpText:"JPEG quality (1-100). Higher = better quality, larger files.",error:n?.("picture_quality")}),r.jsx(S,{label:"Picture Filename Pattern",value:String(s("picture_filename","%Y%m%d%H%M%S-%q")),onChange:p=>t("picture_filename",p),helpText:`Format codes: ${m}`,error:n?.("picture_filename")}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-2 text-sm",children:"Format Code Reference"}),r.jsxs("div",{className:"text-xs text-gray-400 space-y-2",children:[r.jsx("p",{className:"font-medium text-gray-300",children:"Dynamic Folder Examples (Recommended):"}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y-%m-%d/%H%M%S-%q"})," → ",r.jsx("code",{children:"2025-01-29/143022-05.jpg"})]}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y/%m/%d/%H%M%S"})," → ",r.jsx("code",{children:"2025/01/29/143022.jpg"})]}),r.jsxs("p",{children:[r.jsx("code",{children:"%$/%Y-%m-%d/%H%M%S"})," → ",r.jsx("code",{children:"Camera1/2025-01-29/143022.jpg"})]}),r.jsx("p",{className:"mt-2 font-medium text-gray-300",children:"Flat Structure:"}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y%m%d%H%M%S-%q"})," → ",r.jsx("code",{children:"20250129143022-05.jpg"})]}),r.jsxs("p",{className:"mt-2",children:["Available codes: ",m]}),r.jsxs("p",{className:"mt-2 text-yellow-200",children:[r.jsx("strong",{children:"Tip:"})," Using date-based folders like ",r.jsx("code",{children:"%Y-%m-%d/"})," keeps files organized and makes browsing faster."]})]})]})]})})}function Ei(){return qe({queryKey:["deviceInfo"],queryFn:async()=>{const e=await fetch("/0/api/system/status");if(!e.ok)throw new Error("Failed to fetch device info");return e.json()},staleTime:6e4,retry:1})}function At(e){return e?.pi_generation===5}function Ri(e){return e?.pi_generation===4}function pe(e){return e?.hardware_encoders?.h264_v4l2m2m===!0}function Fi(e,t=70){return(e?.temperature?.celsius??0)>t}function Zi({config:e,onChange:t,getError:n,showPassthrough:s=!0}){const{data:a}=Ei(),o=(v,$="")=>e[v]?.value??$,i=o("movie_output",!1),c=o("movie_output_motion",!1),l=o("emulate_motion",!1),u=Zr(i,c,l),[d,m]=x.useState(u),p=v=>{m(v);const $=Ur(v);t("movie_output",$.movie_output),$.movie_output_motion!==void 0&&t("movie_output_motion",$.movie_output_motion),$.emulate_motion!==void 0&&t("emulate_motion",$.emulate_motion)},f=[{value:"off",label:"Off"},{value:"motion-triggered",label:"Motion Triggered"},{value:"continuous",label:"Continuous Recording"}],y=["%Y - Year","%m - Month","%d - Day","%H - Hour","%M - Minute","%S - Second","%v - Event number","%$ - Camera name"].join(", "),g=String(o("movie_container","mp4")),w=()=>!a||pe(a)?ct:ct.filter(v=>!re(v.value)),_=()=>!(o("movie_passthrough",!1)||re(g)||g==="webm");return r.jsx(O,{title:"Movie Settings",description:"Configure video recording settings",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsx(R,{label:"Recording Mode",value:d,onChange:p,options:f,helpText:"When to record video. Motion Triggered = only during events, Continuous = always record."}),r.jsx(P,{label:"Movie Quality",value:Number(o("movie_quality",75)),onChange:v=>t("movie_quality",v),min:1,max:100,unit:"%",helpText:"Video encoding quality (1-100). Higher = better quality, larger files, more CPU.",error:n?.("movie_quality")}),r.jsx(S,{label:"Movie Filename Pattern",value:String(o("movie_filename","%Y%m%d%H%M%S")),onChange:v=>t("movie_filename",v),helpText:`Format codes: ${y}`,error:n?.("movie_filename")}),r.jsx(R,{label:"Container Format",value:String(o("movie_container","mp4")),onChange:v=>t("movie_container",v),options:w(),helpText:"Video container format. Hardware encoding requires v4l2m2m support."}),re(g)&&pe(a)&&r.jsxs("div",{className:"text-xs text-green-400 bg-green-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"Hardware Encoding Active:"})," Using h264_v4l2m2m hardware encoder for ~10% CPU usage instead of 40-70%."]}),a&&!pe(a)&&r.jsxs("div",{className:"text-xs text-blue-400 bg-blue-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"Hardware Encoding Not Available:"})," This device does not have a hardware H.264 encoder.",At(a)&&" Pi 5 does not include a hardware encoder."," ","Hardware encoding options (h264_v4l2m2m) are hidden. Using software encoding (~40-70% CPU)."]}),re(g)&&!a&&r.jsxs("div",{className:"text-xs text-blue-400 bg-blue-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"Hardware Encoding:"})," Uses h264_v4l2m2m hardware encoder for ~10% CPU usage instead of 40-70%. Only available on devices with v4l2m2m support."]}),Ie(g)&&r.jsxs("div",{className:"text-xs text-amber-400 bg-amber-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"High CPU Warning:"})," H.265/HEVC software encoding uses 80-100% CPU on Raspberry Pi. Not recommended for continuous recording. Consider H.264 for better performance."]}),pe(a)&&!re(g)&&!o("movie_passthrough",!1)&&!g.includes("webm")&&!Ie(g)&&r.jsxs("div",{className:"text-xs text-blue-400 bg-blue-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"Hardware Encoding Available:"}),' This device has a hardware H.264 encoder. Select "MKV - H.264 Hardware" or "MP4 - H.264 Hardware" to reduce CPU from ~40-70% to ~10%.']}),!a&&!re(g)&&!o("movie_passthrough",!1)&&!g.includes("webm")&&!Ie(g)&&r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded mt-2",children:[r.jsx("strong",{children:"Tip:"}),' If your device has hardware encoding support (e.g., Raspberry Pi 4), consider selecting "MKV - H.264 Hardware" or "MP4 - H.264 Hardware" for ~10% CPU instead of ~40-70% with software encoding.']}),g==="webm"&&r.jsxs("div",{className:"text-xs text-blue-400 bg-blue-950/30 p-3 rounded mt-2",children:[r.jsx("strong",{children:"WebM Format:"})," Uses VP8 codec, optimized for web streaming. Encoder preset setting does not apply to VP8."]}),_()&&r.jsx(R,{label:"Encoder Preset",value:String(o("movie_encoder_preset","medium")),onChange:v=>t("movie_encoder_preset",v),options:Lr.map(v=>({value:v.value,label:v.label})),helpText:"Tradeoff between CPU usage and video quality. Lower presets use less CPU but produce lower quality video. Requires restart to take effect."}),r.jsx(S,{label:"Max Duration (seconds)",value:String(o("movie_max_time",0)),onChange:v=>t("movie_max_time",Number(v)),type:"number",min:"0",helpText:"Maximum movie length (0 = unlimited). Splits long events into multiple files.",error:n?.("movie_max_time")}),s&&r.jsx(Y,{label:"Passthrough Mode",value:o("movie_passthrough",!1),onChange:v=>t("movie_passthrough",v),helpText:"Copy codec without re-encoding (NETCAM only). Reduces CPU but may cause compatibility issues."}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-2 text-sm",children:"Format Code Reference"}),r.jsxs("div",{className:"text-xs text-gray-400 space-y-2",children:[r.jsx("p",{className:"font-medium text-gray-300",children:"Dynamic Folder Examples (Recommended):"}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y-%m-%d/%H%M%S"})," → ",r.jsx("code",{children:"2025-01-29/143022.mkv"})]}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y/%m/%d/%v-%H%M%S"})," → ",r.jsx("code",{children:"2025/01/29/42-143022.mkv"})]}),r.jsxs("p",{children:[r.jsx("code",{children:"%$/%Y-%m-%d/%v"})," → ",r.jsx("code",{children:"Camera1/2025-01-29/42.mkv"})]}),r.jsx("p",{className:"mt-2 font-medium text-gray-300",children:"Flat Structure:"}),r.jsxs("p",{children:[r.jsx("code",{children:"%Y%m%d%H%M%S"})," → ",r.jsx("code",{children:"20250129143022.mkv"})]}),r.jsxs("p",{className:"mt-2",children:["Available codes: ",y]}),r.jsxs("p",{className:"mt-2 text-yellow-200",children:[r.jsx("strong",{children:"Tip:"})," Using date-based folders like ",r.jsx("code",{children:"%Y-%m-%d/"})," keeps files organized and makes browsing faster."]})]})]}),d==="continuous"&&At(a)&&!o("movie_passthrough",!1)&&r.jsxs("div",{className:"text-xs text-amber-400 bg-amber-950/30 p-3 rounded",children:[r.jsx("strong",{children:"Pi 5 CPU Warning:"})," Pi 5 does not have a hardware H.264 encoder. Continuous recording uses software encoding (~35-60% CPU constant).",r.jsxs("ul",{className:"list-disc ml-4 mt-1",children:[r.jsx("li",{children:'Use encoder preset "Ultrafast" to reduce CPU by ~30%'}),r.jsx("li",{children:"Add active cooling (fan) to prevent thermal throttling"}),r.jsx("li",{children:"Enable passthrough if source is already H.264"})]})]}),d==="continuous"&&Ri(a)&&pe(a)&&r.jsxs("div",{className:"text-xs text-green-400 bg-green-950/30 p-3 rounded",children:[r.jsx("strong",{children:"Continuous Recording on Pi 4:"})," Camera will record 24/7.",re(g)?r.jsx("span",{children:" Using hardware encoder - expect ~10% CPU usage."}):o("movie_passthrough",!1)?r.jsx("span",{children:" Passthrough mode enabled - expect ~5-10% CPU usage."}):r.jsx("span",{children:" Consider using hardware encoder (MKV/MP4 H.264 Hardware) for ~10% CPU instead of ~40-70%."})]}),d==="continuous"&&!a&&r.jsxs("div",{className:"text-xs text-yellow-200 bg-yellow-600/10 border border-yellow-600/30 p-3 rounded",children:[r.jsx("strong",{children:"Continuous Recording:"})," Camera will record 24/7 regardless of motion. Expected CPU usage on Raspberry Pi:",r.jsxs("ul",{className:"list-disc ml-4 mt-1",children:[r.jsxs("li",{children:[r.jsx("strong",{children:"Pi 4 with hardware encoder:"})," ~10% CPU"]}),r.jsxs("li",{children:[r.jsx("strong",{children:"Pi 5 or Pi 4 software encoding:"})," ~35-60% CPU depending on preset"]}),r.jsxs("li",{children:[r.jsx("strong",{children:"Passthrough mode:"})," ~5-10% CPU (if source is H.264)"]})]})]}),Fi(a)&&r.jsxs("div",{className:"text-xs text-red-400 bg-red-950/30 p-3 rounded",children:[r.jsx("strong",{children:"High Temperature Warning:"})," Device is running at ",a?.temperature?.celsius.toFixed(1),"°C. Consider reducing encoding quality or adding active cooling."]})]})})}function Li({config:e,onChange:t,getError:n,originalConfig:s}){const a=(c,l="")=>e[c]?.value??l,o=(c,l="")=>s?.[c]?.value??l,i=["%Y - Year (4 digits)","%m - Month (01-12)","%d - Day (01-31)","%H - Hour (00-23)","%M - Minute (00-59)","%S - Second (00-59)","%q - Frame number","%v - Event number","%$ - Camera name"].join(", ");return r.jsx(O,{title:"Storage",description:"Base directory and periodic snapshot settings for this camera",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsx(S,{label:"Base Storage Directory",value:String(a("target_dir","/var/lib/motion")),onChange:c=>t("target_dir",c),helpText:"Root directory for ALL camera files. Picture and movie filename patterns (configured in their sections) create paths relative to this directory.",error:n?.("target_dir"),originalValue:String(o("target_dir","/var/lib/motion"))}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"Filename Patterns"}),r.jsx(S,{label:"Snapshot Filename",value:String(a("snapshot_filename","%Y%m%d%H%M%S-snapshot")),onChange:c=>t("snapshot_filename",c),helpText:"Format for periodic snapshot filenames (strftime syntax)",error:n?.("snapshot_filename")}),r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded mb-4",children:[r.jsxs("p",{children:[r.jsx("strong",{children:"Example:"})," ",r.jsx("code",{children:"%Y%m%d%H%M%S-snapshot"})," → ",r.jsx("code",{children:"20250129143022-snapshot.jpg"})]}),r.jsxs("p",{children:[r.jsx("strong",{children:"With subdirs:"})," ",r.jsx("code",{children:"%$/%Y-%m-%d/snapshot-%H%M%S"})," → ",r.jsx("code",{children:"Camera1/2025-01-29/snapshot-143022.jpg"})]})]})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3 text-sm",children:"Format Code Reference"}),r.jsxs("div",{className:"text-xs text-gray-400 space-y-1",children:[r.jsxs("p",{children:["Available codes: ",i]}),r.jsxs("p",{className:"mt-2 text-blue-200",children:[r.jsx("strong",{children:"How it works:"})," The Base Storage Directory above sets where files go. Picture and Movie sections set filename patterns (which can include subdirectories like ",r.jsx("code",{children:"%Y-%m-%d/"}),")."]})]})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"File Cleanup (Future)"}),r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded",children:[r.jsx("p",{children:"Automatic file retention and cleanup based on age/size will be available in a future update."}),r.jsxs("p",{className:"mt-2",children:["For now, use ",r.jsx("code",{children:"cleandir_params"})," in the Motion configuration file or manual cleanup scripts."]})]})]}),r.jsxs("div",{className:"text-xs text-yellow-200 bg-yellow-600/10 border border-yellow-600/30 p-3 rounded",children:[r.jsx("strong",{children:"💡 Network Storage:"})," For network shares (NFS, SMB), ensure target_dir points to a mounted directory. Test write permissions before starting recording."]})]})})}const te=["sun","mon","tue","wed","thu","fri","sat"],Dt={sun:{full:"Sunday",short:"Sun"},mon:{full:"Monday",short:"Mon"},tue:{full:"Tuesday",short:"Tue"},wed:{full:"Wednesday",short:"Wed"},thu:{full:"Thursday",short:"Thu"},fri:{full:"Friday",short:"Fri"},sat:{full:"Saturday",short:"Sat"}},ne=96,be=15;function ur(){return{sun:new Set,mon:new Set,tue:new Set,wed:new Set,thu:new Set,fri:new Set,sat:new Set}}function Ui(e){const t=new Map,n=e.trim().split(/\s+/);for(const s of n){const a=s.indexOf("=");if(a===-1)continue;const o=s.slice(0,a).toLowerCase(),i=s.slice(a+1);t.has(o)||t.set(o,[]),t.get(o).push(i)}return t}function Hi(e){if(e.length!==9||e[4]!=="-")return[];const t=parseInt(e.slice(0,2),10),n=parseInt(e.slice(2,4),10),s=parseInt(e.slice(5,7),10),a=parseInt(e.slice(7,9),10);if(isNaN(t)||isNaN(n)||isNaN(s)||isNaN(a)||t<0||t>23||n<0||n>59||s<0||s>23||a<0||a>59)return[];const o=t*4+Math.floor(n/be),i=s*4+Math.floor(a/be),c=[];for(let l=o;l<=i&&lc-l),n=[];let s=t[0],a=t[0];for(let c=1;cEt(e[i],e.sun))&&e.sun.size>0){const i=Ne(Array.from(e.sun));for(const c of i)s.push(`sun-sat=${Se(c)}`);return s.join(" ")}if(["mon","tue","wed","thu","fri"].every(i=>Et(e[i],e.mon))&&e.mon.size>0){const i=Ne(Array.from(e.mon));for(const c of i)s.push(`mon-fri=${Se(c)}`);for(const c of["sat","sun"])if(e[c].size>0){const l=Ne(Array.from(e[c]));for(const u of l)s.push(`${c}=${Se(u)}`)}return s.join(" ")}for(const i of te){if(e[i].size===0)continue;const c=Ne(Array.from(e[i]));for(const l of c)s.push(`${i}=${Se(l)}`)}return s.join(" ")}function Et(e,t){if(e.size!==t.size)return!1;for(const n of e)if(!t.has(n))return!1;return!0}function Bi(e,t){const n=Be(e),s=Ve(t),a=(o,i)=>{const c=o===0?12:o>12?o-12:o,l=o<12?"am":"pm",u=i===0?"":`:${String(i).padStart(2,"0")}`;return`${c}${u}${l}`};return`${a(n.hour,n.min)} - ${a(s.hour,s.min)}`}function Wi(e){return te.every(t=>e[t].size===0)}function Yi(e){const t=te.filter(s=>e[s].size>0);return t.length===0?"No time ranges selected":t.length===7?"All days configured":t.map(s=>s.charAt(0).toUpperCase()+s.slice(1,3)).join(", ")}function qi({value:e,onChange:t}){const{schedule:n,defaultOn:s,action:a}=x.useMemo(()=>Vi(e),[e]),o=x.useCallback(f=>{const y=Re(f,s,a);t(y)},[t,s,a]),i=x.useCallback(f=>{const y=Fe(n);y[f].size===ne?y[f]=new Set:y[f]=new Set(Array.from({length:ne},(g,w)=>w)),o(y)},[n,o]),c=x.useCallback((f,y,g,w)=>{const _=Fe(n),v=new Set(n[f]),[$,B]=y<=g?[y,g]:[g,y];for(let Z=$;Z<=B;Z++)w?v.add(Z):v.delete(Z);_[f]=v,o(_)},[n,o]),l=x.useCallback(f=>{const y=Fe(n);y[f]=new Set,o(y)},[n,o]),u=x.useCallback(()=>{o(ur())},[o]),d=x.useCallback(f=>{const y=Re(n,f,a);t(y)},[n,a,t]),m=x.useCallback(f=>{const y=Re(n,s,f);t(y)},[n,s,t]),p=x.useCallback(f=>{t(f)},[t]);return{schedule:n,defaultOn:s,action:a,updateSchedule:o,toggleDay:i,setRange:c,clearDay:l,clearAll:u,setDefaultOn:d,setAction:m,applyPreset:p}}function Fe(e){return{sun:new Set(e.sun),mon:new Set(e.mon),tue:new Set(e.tue),wed:new Set(e.wed),thu:new Set(e.thu),fri:new Set(e.fri),sat:new Set(e.sat)}}const Ji=x.memo(function({isSelected:t,isInDragRange:n,isDragSelect:s,isHourBoundary:a,onPointerDown:o,onPointerEnter:i}){let c;n?c=s?"bg-primary/70":"bg-surface-hover":t?c="bg-primary":c="bg-surface";const l=a?"border-t border-gray-700/50":"";return r.jsx("div",{className:`h-[6px] ${c} ${l} cursor-pointer transition-colors duration-75`,onPointerDown:o,onPointerEnter:i})}),Gi=x.memo(function({day:t,schedule:n,dragState:s,onPointerDown:a,onPointerMove:o}){const i=x.useMemo(()=>{if(!s.isDragging||s.startDay!==t)return null;const l=s.startIndex,u=s.currentIndex;return{from:Math.min(l,u),to:Math.max(l,u)}},[s.isDragging,s.startDay,s.startIndex,s.currentIndex,t]),c=x.useMemo(()=>Array.from({length:ne},(l,u)=>({isSelected:n.has(u),isInDragRange:i!==null&&u>=i.from&&u<=i.to,isHourBoundary:u%4===0})),[n,i]);return r.jsx("div",{className:"flex flex-col",children:c.map((l,u)=>r.jsx(Ji,{index:u,isSelected:l.isSelected,isInDragRange:l.isInDragRange,isDragSelect:s.selectMode,isHourBoundary:l.isHourBoundary,onPointerDown:()=>a(u),onPointerEnter:()=>o(u)},u))})}),Ki=[0,2,4,6,8,10,12,14,16,18,20,22];function Qi(e){return e===0?"12a":e===12?"12p":e<12?`${e}a`:`${e-12}p`}const Xi=x.memo(function(){return r.jsx("div",{className:"flex flex-col pr-1 text-xs text-gray-500 select-none",children:Ki.map(t=>r.jsx("div",{className:"flex items-start justify-end",style:{height:"48px"},children:r.jsx("span",{className:"-mt-1.5",children:Qi(t)})},t))})}),Ce={isDragging:!1,startDay:null,startIndex:null,currentIndex:null,selectMode:!0};function ec({schedule:e,onScheduleChange:t,disabled:n=!1}){const[s,a]=x.useState(Ce),o=x.useRef(null),i=x.useCallback((p,f)=>{if(n)return;const y=e[p].has(f);a({isDragging:!0,startDay:p,startIndex:f,currentIndex:f,selectMode:!y})},[e,n]),c=x.useCallback((p,f)=>{!s.isDragging||p!==s.startDay||a(y=>({...y,currentIndex:f}))},[s.isDragging,s.startDay]),l=x.useCallback(()=>{if(!s.isDragging||s.startDay===null||s.startIndex===null||s.currentIndex===null){a(Ce);return}const p={sun:new Set(e.sun),mon:new Set(e.mon),tue:new Set(e.tue),wed:new Set(e.wed),thu:new Set(e.thu),fri:new Set(e.fri),sat:new Set(e.sat)},f=new Set(e[s.startDay]),[y,g]=s.startIndex<=s.currentIndex?[s.startIndex,s.currentIndex]:[s.currentIndex,s.startIndex];for(let w=y;w<=g;w++)s.selectMode?f.add(w):f.delete(w);p[s.startDay]=f,t(p),a(Ce)},[s,e,t]);x.useEffect(()=>{const p=f=>{f.key==="Escape"&&s.isDragging&&a(Ce)};return window.addEventListener("keydown",p),()=>window.removeEventListener("keydown",p)},[s.isDragging]),x.useEffect(()=>(s.isDragging?(document.body.style.touchAction="none",document.body.style.userSelect="none"):(document.body.style.touchAction="",document.body.style.userSelect=""),()=>{document.body.style.touchAction="",document.body.style.userSelect=""}),[s.isDragging]);const u=x.useCallback(p=>{if(n)return;const f={sun:new Set(e.sun),mon:new Set(e.mon),tue:new Set(e.tue),wed:new Set(e.wed),thu:new Set(e.thu),fri:new Set(e.fri),sat:new Set(e.sat)};f[p].size===ne?f[p]=new Set:f[p]=new Set(Array.from({length:ne},(y,g)=>g)),t(f)},[e,t,n]),m=(()=>{if(!s.isDragging||s.startIndex===null||s.currentIndex===null)return null;const p=Math.min(s.startIndex,s.currentIndex),f=Math.max(s.startIndex,s.currentIndex);return Bi(p,f)})();return r.jsxs("div",{className:"select-none",children:[r.jsxs("div",{className:"flex mb-1",children:[r.jsx("div",{className:"w-8 shrink-0"}),r.jsx("div",{className:"grid grid-cols-7 gap-px flex-1",children:te.map(p=>{const f=e[p].size===ne,y=e[p].size>0;return r.jsxs("button",{type:"button",onClick:()=>u(p),disabled:n,className:`text-xs font-medium py-1 rounded-t transition-colors ${f?"bg-primary text-white":y?"bg-primary/30 text-primary":"bg-surface-elevated text-gray-400 hover:bg-surface-hover"} ${n?"cursor-not-allowed opacity-50":"cursor-pointer"}`,title:`Click to ${f?"clear":"select all"} ${Dt[p].full}`,children:[r.jsx("span",{className:"hidden sm:inline",children:Dt[p].short}),r.jsx("span",{className:"sm:hidden",children:p.charAt(0).toUpperCase()})]},p)})})]}),r.jsxs("div",{className:"flex",children:[r.jsx("div",{className:"w-8 shrink-0",children:r.jsx(Xi,{})}),r.jsx("div",{ref:o,className:`grid grid-cols-7 gap-px bg-surface-elevated flex-1 rounded ${n?"opacity-50":""}`,style:{touchAction:"none"},onPointerUp:l,onPointerLeave:l,onPointerCancel:l,children:te.map(p=>r.jsx(Gi,{day:p,schedule:e[p],dragState:s,onPointerDown:f=>i(p,f),onPointerMove:f=>c(p,f)},p))})]}),m&&r.jsx("div",{className:"mt-2 text-center",children:r.jsxs("span",{className:"text-xs bg-surface-elevated px-2 py-1 rounded text-gray-300",children:[s.selectMode?"Selecting":"Deselecting",":"," ",r.jsx("span",{className:"text-white font-medium",children:m})]})}),r.jsx("div",{className:"mt-2 text-xs text-gray-500 text-center",children:"Click and drag to select time ranges. Click day header to toggle entire day."})]})}const tc=[{label:"Business Hours",value:"default=true action=pause mon-fri=0900-1700",description:"Pause Mon-Fri 9am-5pm"},{label:"Night Watch",value:"default=false action=pause sun-sat=1800-2359 sun-sat=0000-0600",description:"Active 6pm-6am only"},{label:"Weekends Only",value:"default=false action=pause sat=0000-2359 sun=0000-2359",description:"Active Sat & Sun only"},{label:"Always On",value:"default=true action=pause",description:"No schedule restrictions"}],rc=x.memo(function({defaultOn:t,action:n,onDefaultOnChange:s,onActionChange:a,onApplyPreset:o,onClearAll:i,disabled:c=!1}){return r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-xs text-gray-400 mb-1",children:"Detection Default"}),r.jsxs("select",{value:t?"on":"off",onChange:l=>s(l.target.value==="on"),disabled:c,className:"w-full bg-surface-elevated border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed",children:[r.jsx("option",{value:"on",children:"On by default"}),r.jsx("option",{value:"off",children:"Off by default"})]}),r.jsx("p",{className:"text-xs text-gray-500 mt-1",children:t?"Schedule defines when detection is paused":"Schedule defines when detection is active"})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-xs text-gray-400 mb-1",children:"Schedule Action"}),r.jsxs("select",{value:n,onChange:l=>a(l.target.value),disabled:c,className:"w-full bg-surface-elevated border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed",children:[r.jsx("option",{value:"pause",children:"Pause detection"}),r.jsx("option",{value:"stop",children:"Stop camera"})]}),r.jsx("p",{className:"text-xs text-gray-500 mt-1",children:n==="pause"?"Camera runs but ignores motion":"Camera completely stops during schedule"})]})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-xs text-gray-400 mb-2",children:"Quick Presets"}),r.jsxs("div",{className:"flex flex-wrap gap-2",children:[tc.map(l=>r.jsx("button",{type:"button",onClick:()=>o(l.value),disabled:c,className:"px-3 py-1.5 text-xs bg-surface-elevated hover:bg-surface-hover border border-gray-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",title:l.description,children:l.label},l.label)),r.jsx("button",{type:"button",onClick:i,disabled:c,className:"px-3 py-1.5 text-xs bg-danger/20 hover:bg-danger/30 text-danger border border-danger/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",title:"Clear all time ranges",children:"Clear All"})]})]})]})});function Rt({value:e,onChange:t,helpText:n,error:s,disabled:a=!1}){const{schedule:o,defaultOn:i,action:c,updateSchedule:l,setDefaultOn:u,setAction:d,applyPreset:m,clearAll:p}=qi({value:e,onChange:t}),f=Wi(o),y=Yi(o);return r.jsxs("div",{className:"space-y-4",children:[r.jsx(rc,{defaultOn:i,action:c,onDefaultOnChange:u,onActionChange:d,onApplyPreset:m,onClearAll:p,disabled:a}),r.jsx("div",{className:"border border-gray-700 rounded-lg p-4 bg-surface",children:r.jsx(ec,{schedule:o,onScheduleChange:l,disabled:a})}),r.jsxs("div",{className:"flex items-center justify-between text-sm",children:[r.jsx("span",{className:"text-gray-400",children:f?r.jsx("span",{className:"text-yellow-400",children:"No time ranges configured"}):r.jsxs(r.Fragment,{children:["Schedule: ",r.jsx("span",{className:"text-white",children:y})]})}),i?r.jsx("span",{className:"text-xs text-gray-500",children:"Detection paused during selected times"}):r.jsx("span",{className:"text-xs text-gray-500",children:"Detection active during selected times"})]}),n&&!s&&r.jsx("p",{className:"text-sm text-gray-400",children:n}),s&&r.jsx("p",{className:"text-sm text-red-400",role:"alert",children:s}),r.jsxs("details",{className:"text-xs",children:[r.jsx("summary",{className:"cursor-pointer text-gray-500 hover:text-gray-400",children:"Show raw schedule format"}),r.jsx("code",{className:"block mt-2 p-2 bg-surface-elevated rounded text-gray-400 break-all",children:e||"(empty)"})]})]})}function nc({config:e,onChange:t,getError:n}){const s=(d,m="")=>e[d]?.value??m,a=String(s("schedule_params","")),o=a.trim()!=="",i=d=>{d?t("schedule_params","default=true action=pause mon-fri=0900-1700"):t("schedule_params","")},c=String(s("picture_schedule_params","")),l=c.trim()!=="",u=d=>{d?t("picture_schedule_params","default=false action=pause mon-fri=0900-1700"):t("picture_schedule_params","")};return r.jsx(O,{title:"Schedules",description:"Configure when motion detection and continuous recording are active",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-6",children:[r.jsxs("div",{className:"space-y-4",children:[r.jsx("h4",{className:"text-sm font-medium border-b border-gray-700 pb-2",children:"Motion Detection Schedule"}),r.jsx("p",{className:"text-xs text-gray-400",children:"Control when motion detection is active or paused"}),r.jsx(Y,{label:"Enable Motion Detection Schedule",value:o,onChange:i,helpText:"When enabled, motion detection follows the schedule below"}),o&&r.jsx(Rt,{value:a,onChange:d=>t("schedule_params",d),error:n?.("schedule_params")})]}),r.jsx("div",{className:"border-t border-gray-700"}),r.jsxs("div",{className:"space-y-4",children:[r.jsx("h4",{className:"text-sm font-medium border-b border-gray-700 pb-2",children:"Continuous Recording Schedule"}),r.jsx("p",{className:"text-xs text-gray-400",children:"Control when continuous picture capture (timelapse) is active"}),r.jsx(Y,{label:"Enable Continuous Recording Schedule",value:l,onChange:u,helpText:"When enabled, continuous recording follows the schedule below"}),l&&r.jsx(Rt,{value:c,onChange:d=>t("picture_schedule_params",d),error:n?.("picture_schedule_params")})]})]})})}const We={gridColumns:2,gridRows:2,fitFramesVertically:!1,playbackFramerateFactor:1,playbackResolutionFactor:1,theme:"dark"};function sc(){const e=localStorage.getItem("motion-ui-preferences");if(e)try{const t=JSON.parse(e);return{...We,...t}}catch(t){console.error("Failed to parse preferences:",t)}return We}function ac(){const[e,t]=x.useState(sc),n=(s,a)=>{const o={...e,[s]:a};t(o),localStorage.setItem("motion-ui-preferences",JSON.stringify(o))};return r.jsx(O,{title:"UI Preferences",description:"User interface preferences (stored locally in browser)",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded mb-4",children:[r.jsx("strong",{children:"Note:"})," These preferences are stored in your browser's localStorage and are not saved to the Motion server. They are specific to this browser/device."]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"Dashboard Layout"}),r.jsx(P,{label:"Grid Columns",value:e.gridColumns,onChange:s=>n("gridColumns",s),min:1,max:4,helpText:"Number of camera columns in dashboard grid (1-4)"}),r.jsx(P,{label:"Grid Rows",value:e.gridRows,onChange:s=>n("gridRows",s),min:1,max:4,helpText:"Number of camera rows in dashboard grid (1-4)"}),r.jsx(Y,{label:"Fit Frames Vertically",value:e.fitFramesVertically,onChange:s=>n("fitFramesVertically",s),helpText:"Fit camera frames to viewport height instead of width"})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"Playback Settings"}),r.jsx(P,{label:"Framerate Factor",value:e.playbackFramerateFactor,onChange:s=>n("playbackFramerateFactor",s),min:.1,max:4,step:.1,unit:"x",helpText:"Playback speed multiplier (0.5 = half speed, 2.0 = double speed)"}),r.jsx(P,{label:"Resolution Factor",value:e.playbackResolutionFactor,onChange:s=>n("playbackResolutionFactor",s),min:.25,max:1,step:.25,unit:"x",helpText:"Playback resolution scaling (0.5 = half resolution, 1.0 = full)"}),r.jsx("div",{className:"text-xs text-gray-400",children:r.jsx("p",{children:"Lower resolution factors reduce bandwidth and improve performance on slow connections."})})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("h4",{className:"font-medium mb-3",children:"Appearance"}),r.jsx(R,{label:"Theme",value:e.theme,onChange:s=>n("theme",s),options:[{value:"dark",label:"Dark"},{value:"light",label:"Light (Coming Soon)"},{value:"auto",label:"Auto (System Preference)"}],helpText:"UI color theme",disabled:!0}),r.jsxs("div",{className:"text-xs text-yellow-200 bg-yellow-600/10 border border-yellow-600/30 p-3 rounded",children:[r.jsx("strong",{children:"Note:"})," Light theme and auto theme switching will be available in a future update."]})]}),r.jsxs("div",{className:"border-t border-surface-elevated pt-4",children:[r.jsx("button",{onClick:()=>{localStorage.removeItem("motion-ui-preferences"),t(We)},className:"px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg transition-colors text-sm",children:"Reset to Defaults"}),r.jsx("p",{className:"text-xs text-gray-400 mt-2",children:"Clears all saved preferences and returns to default settings"})]})]})})}const Ze={playbackFramerateFactor:1,playbackResolutionFactor:1};function oc(){const e=localStorage.getItem("motion-ui-preferences");if(e)try{const t=JSON.parse(e);return{playbackFramerateFactor:t.playbackFramerateFactor??Ze.playbackFramerateFactor,playbackResolutionFactor:t.playbackResolutionFactor??Ze.playbackResolutionFactor}}catch(t){console.error("Failed to parse preferences:",t)}return Ze}function ic(){const[e,t]=x.useState(oc),n=(s,a)=>{const o={...e,[s]:a};t(o);const i=localStorage.getItem("motion-ui-preferences");let c={};if(i)try{c=JSON.parse(i)}catch(l){console.error("Failed to parse existing preferences:",l)}localStorage.setItem("motion-ui-preferences",JSON.stringify({...c,...o}))};return r.jsx(O,{title:"Playback Settings",description:"Video playback preferences for this camera (stored locally in browser)",collapsible:!0,defaultOpen:!1,children:r.jsxs("div",{className:"space-y-4",children:[r.jsxs("div",{className:"text-xs text-gray-400 bg-surface-elevated p-3 rounded mb-4",children:[r.jsx("strong",{children:"Note:"})," These preferences are stored in your browser's localStorage and are not saved to the Motion server. They are specific to this browser/device."]}),r.jsx(P,{label:"Framerate Factor",value:e.playbackFramerateFactor,onChange:s=>n("playbackFramerateFactor",s),min:.1,max:4,step:.1,unit:"x",helpText:"Playback speed multiplier (0.5 = half speed, 2.0 = double speed)"}),r.jsx(P,{label:"Resolution Factor",value:e.playbackResolutionFactor,onChange:s=>n("playbackResolutionFactor",s),min:.25,max:1,step:.25,unit:"x",helpText:"Playback resolution scaling (0.5 = half resolution, 1.0 = full)"}),r.jsx("div",{className:"text-xs text-gray-400",children:r.jsx("p",{children:"Lower resolution factors reduce bandwidth and improve performance on slow connections."})})]})})}function cc({cameraId:e}){const{addToast:t}=Ye(),n=Zt(),s=x.useRef(null),a=x.useRef(null),[o,i]=x.useState("motion"),[c,l]=x.useState("rectangle"),[u,d]=x.useState(!1),[m,p]=x.useState(null),[f,y]=x.useState(null),[g,w]=x.useState([]),[_,v]=x.useState([]),[$,B]=x.useState(!1),[Z,D]=x.useState(!1),{data:W,isLoading:de}=qe({queryKey:["mask",e,o],queryFn:()=>Lt(`/${e}/api/mask/${o}`)}),[T,C]=x.useState({width:640,height:480}),z=nt({mutationFn:k=>Pe(`/${e}/api/mask/${o}`,k),onSuccess:()=>{t(`${o==="motion"?"Motion":"Privacy"} mask saved`,"success"),n.invalidateQueries({queryKey:["mask",e,o]})},onError:()=>{t("Failed to save mask","error")}}),b=nt({mutationFn:()=>wr(`/${e}/api/mask/${o}`),onSuccess:()=>{t("Mask deleted","success"),w([]),n.invalidateQueries({queryKey:["mask",e,o]})},onError:()=>{t("Failed to delete mask","error")}}),F=x.useCallback(k=>{const N=s.current;if(!N)return{x:0,y:0};const M=N.getBoundingClientRect(),X=T.width/M.width,oe=T.height/M.height;return{x:Math.round((k.clientX-M.left)*X),y:Math.round((k.clientY-M.top)*oe)}},[T]),Q=x.useCallback(()=>{const k=s.current;if(!k)return;const N=k.getContext("2d");if(N&&(N.clearRect(0,0,k.width,k.height),N.fillStyle="rgba(255, 0, 0, 0.4)",N.strokeStyle="rgba(255, 0, 0, 0.8)",N.lineWidth=2,g.forEach(M=>{M.length<3||(N.beginPath(),N.moveTo(M[0].x,M[0].y),M.slice(1).forEach(X=>N.lineTo(X.x,X.y)),N.closePath(),N.fill(),N.stroke())}),_.length>0&&(N.beginPath(),N.moveTo(_[0].x,_[0].y),_.slice(1).forEach(M=>N.lineTo(M.x,M.y)),f&&N.lineTo(f.x,f.y),N.stroke(),N.fillStyle="rgba(255, 255, 0, 0.8)",_.forEach(M=>{N.beginPath(),N.arc(M.x,M.y,4,0,Math.PI*2),N.fill()})),c==="rectangle"&&u&&m&&f)){N.fillStyle="rgba(255, 0, 0, 0.4)",N.strokeStyle="rgba(255, 0, 0, 0.8)";const M=Math.min(m.x,f.x),X=Math.min(m.y,f.y),oe=Math.abs(f.x-m.x),ye=Math.abs(f.y-m.y);N.fillRect(M,X,oe,ye),N.strokeRect(M,X,oe,ye)}},[g,_,f,m,u,c]);x.useEffect(()=>{Q()},[Q]);const J=x.useCallback(k=>{const N=F(k);c==="rectangle"?(d(!0),p(N),y(N)):v(M=>[...M,N])},[c,F]),Oe=x.useCallback(k=>{const N=F(k);y(N)},[F]),me=x.useCallback(()=>{if(c==="rectangle"&&u&&m&&f){const k=Math.min(m.x,f.x),N=Math.min(m.y,f.y),M=Math.max(m.x,f.x),X=Math.max(m.y,f.y);if(M-k>5&&X-N>5){const oe=[{x:k,y:N},{x:M,y:N},{x:M,y:X},{x:k,y:X}];w(ye=>[...ye,oe])}}d(!1),p(null)},[c,u,m,f]),G=x.useCallback(()=>{c==="polygon"&&_.length>=3&&(w(k=>[...k,_]),v([]))},[c,_]),he=x.useCallback(()=>{w([]),v([]),p(null),y(null),d(!1)},[]),dr=x.useCallback(()=>{_.length>0?v(k=>k.slice(0,-1)):g.length>0&&w(k=>k.slice(0,-1))},[_.length,g.length]),mr=x.useCallback(()=>{if(g.length===0){t("Draw at least one mask area first","warning");return}z.mutate({polygons:g,width:T.width,height:T.height,invert:$})},[g,T,$,z,t]),hr=x.useCallback(()=>{window.confirm(`Delete the ${o} mask? This cannot be undone.`)&&b.mutate()},[o,b]),pr=x.useCallback(k=>{const N=k.currentTarget;C({width:N.naturalWidth,height:N.naturalHeight}),D(!1)},[]),fr=x.useMemo(()=>{const k=jr(),N=`/${e}/mjpg/stream`;return k?`${N}?token=${encodeURIComponent(k)}`:N},[e]);return r.jsxs(O,{title:"Mask Editor",description:"Draw mask areas on the camera feed to define motion detection or privacy zones",collapsible:!0,defaultOpen:!1,children:[r.jsxs("div",{className:"flex gap-4 mb-4",children:[r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium mb-2",children:"Mask Type"}),r.jsxs("div",{className:"flex gap-2",children:[r.jsx("button",{onClick:()=>{i("motion"),he()},className:`px-3 py-1.5 rounded text-sm transition-colors ${o==="motion"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"Motion"}),r.jsx("button",{onClick:()=>{i("privacy"),he()},className:`px-3 py-1.5 rounded text-sm transition-colors ${o==="privacy"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"Privacy"})]})]}),r.jsxs("div",{children:[r.jsx("label",{className:"block text-sm font-medium mb-2",children:"Draw Mode"}),r.jsxs("div",{className:"flex gap-2",children:[r.jsx("button",{onClick:()=>l("rectangle"),className:`px-3 py-1.5 rounded text-sm transition-colors ${c==="rectangle"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"Rectangle"}),r.jsx("button",{onClick:()=>l("polygon"),className:`px-3 py-1.5 rounded text-sm transition-colors ${c==="polygon"?"bg-primary text-white":"bg-surface-elevated hover:bg-surface"}`,children:"Polygon"})]})]}),r.jsx("div",{className:"flex items-end",children:r.jsxs("label",{className:"flex items-center gap-2 text-sm",children:[r.jsx("input",{type:"checkbox",checked:$,onChange:k=>B(k.target.checked),className:"rounded"}),"Invert (detect outside areas)"]})})]}),de?r.jsx("div",{className:"text-sm text-gray-400 mb-4",children:"Loading mask info..."}):W?.exists?r.jsxs("div",{className:"text-sm text-green-500 mb-4",children:["Current mask: ",W.path," (",W.width,"x",W.height,")"]}):r.jsxs("div",{className:"text-sm text-gray-400 mb-4",children:["No ",o," mask configured"]}),r.jsxs("div",{ref:a,className:"relative bg-black rounded-lg overflow-hidden mb-4",children:[Z?r.jsx("div",{className:"aspect-video bg-surface flex items-center justify-center text-gray-400",children:r.jsxs("div",{className:"text-center",children:[r.jsx("p",{children:"Camera stream unavailable"}),r.jsx("p",{className:"text-xs mt-1",children:"Draw on blank canvas (640x480)"})]})}):r.jsx("img",{src:fr,alt:"Camera stream",onLoad:pr,onError:()=>D(!0),className:"w-full h-auto",style:{display:"block"}}),r.jsx("canvas",{ref:s,width:T.width,height:T.height,onMouseDown:J,onMouseMove:Oe,onMouseUp:me,onMouseLeave:me,onDoubleClick:G,className:"absolute inset-0 w-full h-full cursor-crosshair",style:{touchAction:"none"}})]}),r.jsx("div",{className:"text-xs text-gray-400 mb-4",children:c==="rectangle"?r.jsxs("p",{children:["Click and drag to draw rectangles. Red areas will be ",o==="motion"?"ignored for motion detection":"blacked out for privacy","."]}):r.jsx("p",{children:"Click to add polygon points. Double-click to complete the polygon."})}),r.jsxs("div",{className:"flex gap-3 flex-wrap",children:[r.jsx("button",{onClick:dr,disabled:g.length===0&&_.length===0,className:"px-3 py-1.5 text-sm bg-surface-elevated hover:bg-surface rounded transition-colors disabled:opacity-50",children:"Undo"}),r.jsx("button",{onClick:he,disabled:g.length===0&&_.length===0,className:"px-3 py-1.5 text-sm bg-surface-elevated hover:bg-surface rounded transition-colors disabled:opacity-50",children:"Clear All"}),r.jsx("button",{onClick:mr,disabled:z.isPending||g.length===0,className:"px-4 py-1.5 text-sm bg-primary hover:bg-primary-hover rounded transition-colors disabled:opacity-50",children:z.isPending?"Saving...":"Save Mask"}),W?.exists&&r.jsx("button",{onClick:hr,disabled:b.isPending,className:"px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 rounded transition-colors disabled:opacity-50",children:b.isPending?"Deleting...":"Delete Mask"})]}),g.length>0&&r.jsxs("div",{className:"text-xs text-gray-400 mt-3",children:[g.length," mask area",g.length!==1?"s":""," drawn"]})]})}const fe={custom:{label:"Custom Command",description:"Enter your own shell command",command:""},webhook:{label:"Webhook (HTTP POST)",description:"Send HTTP POST to a URL",command:`curl -s -X POST -H "Content-Type: application/json" -d '{"camera":"%t","event":"%v","time":"%Y-%m-%d %T"}' "YOUR_WEBHOOK_URL"`},telegram:{label:"Telegram Bot",description:"Send message via Telegram Bot API",command:'curl -s -X POST "https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage" -d "chat_id=YOUR_CHAT_ID&text=Motion detected on %t at %Y-%m-%d %T"'},email:{label:"Email (msmtp)",description:"Send email using msmtp",command:'echo -e "Subject: Motion Alert\\n\\nMotion detected on camera %t at %Y-%m-%d %T" | msmtp recipient@example.com'},pushover:{label:"Pushover",description:"Send push notification via Pushover",command:'curl -s -F "token=YOUR_APP_TOKEN" -F "user=YOUR_USER_KEY" -F "message=Motion on %t at %T" https://api.pushover.net/1/messages.json'}};function lc({config:e,onChange:t,getError:n}){const[s,a]=x.useState("custom"),o=(c,l="")=>e[c]?.value??l,i=c=>{const l=fe[s];l.command&&t(c,l.command)};return r.jsxs(O,{title:"Notifications & Scripts",description:"Configure commands that run on motion events. Use script hooks to send notifications via webhooks, Telegram, email, or custom scripts.",collapsible:!0,defaultOpen:!1,children:[r.jsxs("div",{className:"bg-surface rounded-lg p-4 mb-6 text-sm",children:[r.jsx("h4",{className:"font-medium mb-2",children:"Available Variables"}),r.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-3 gap-2 text-xs text-gray-400",children:[r.jsx("code",{children:"%f"})," ",r.jsx("span",{children:"Filename"}),r.jsx("code",{children:"%t"})," ",r.jsx("span",{children:"Camera name"}),r.jsx("code",{children:"%v"})," ",r.jsx("span",{children:"Event number"}),r.jsx("code",{children:"%Y"})," ",r.jsx("span",{children:"Year (4 digit)"}),r.jsx("code",{children:"%m"})," ",r.jsx("span",{children:"Month (01-12)"}),r.jsx("code",{children:"%d"})," ",r.jsx("span",{children:"Day (01-31)"}),r.jsx("code",{children:"%H"})," ",r.jsx("span",{children:"Hour (00-23)"}),r.jsx("code",{children:"%M"})," ",r.jsx("span",{children:"Minute (00-59)"}),r.jsx("code",{children:"%S"})," ",r.jsx("span",{children:"Second (00-59)"}),r.jsx("code",{children:"%T"})," ",r.jsx("span",{children:"Time HH:MM:SS"})]})]}),r.jsxs("div",{className:"mb-6 p-4 bg-surface-elevated rounded-lg",children:[r.jsx("h4",{className:"font-medium mb-3 text-sm",children:"Quick Setup Templates"}),r.jsx("div",{className:"flex flex-wrap gap-2 mb-3",children:Object.keys(fe).map(c=>r.jsx("button",{onClick:()=>a(c),className:`px-3 py-1.5 text-sm rounded transition-colors ${s===c?"bg-primary text-white":"bg-surface hover:bg-surface-elevated"}`,children:fe[c].label},c))}),s!=="custom"&&r.jsxs("div",{className:"text-xs text-gray-400 mb-3",children:[r.jsx("p",{children:fe[s].description}),r.jsx("pre",{className:"mt-2 p-2 bg-surface rounded text-xs overflow-x-auto whitespace-pre-wrap break-all",children:fe[s].command})]}),r.jsxs("div",{className:"flex gap-2",children:[r.jsx("button",{onClick:()=>i("on_event_start"),disabled:s==="custom",className:"px-3 py-1.5 text-xs bg-surface hover:bg-surface-elevated rounded transition-colors disabled:opacity-50",children:"Apply to Event Start"}),r.jsx("button",{onClick:()=>i("on_picture_save"),disabled:s==="custom",className:"px-3 py-1.5 text-xs bg-surface hover:bg-surface-elevated rounded transition-colors disabled:opacity-50",children:"Apply to Picture Save"}),r.jsx("button",{onClick:()=>i("on_movie_end"),disabled:s==="custom",className:"px-3 py-1.5 text-xs bg-surface hover:bg-surface-elevated rounded transition-colors disabled:opacity-50",children:"Apply to Movie End"})]})]}),r.jsx(S,{label:"On Event Start",value:String(o("on_event_start","")),onChange:c=>t("on_event_start",c),placeholder:"Command to run when motion event starts",helpText:"Executed when a new motion event begins",error:n?.("on_event_start")}),r.jsx(S,{label:"On Event End",value:String(o("on_event_end","")),onChange:c=>t("on_event_end",c),placeholder:"Command to run when motion event ends",helpText:"Executed when motion event ends (after gap timeout)",error:n?.("on_event_end")}),r.jsx(S,{label:"On Motion Detected",value:String(o("on_motion_detected","")),onChange:c=>t("on_motion_detected",c),placeholder:"Command to run on each motion frame",helpText:"Executed on every frame with motion (can be frequent!)",error:n?.("on_motion_detected")}),r.jsx(S,{label:"On Picture Save",value:String(o("on_picture_save","")),onChange:c=>t("on_picture_save",c),placeholder:"Command to run when picture is saved",helpText:"Executed after a snapshot is saved (%f = filename)",error:n?.("on_picture_save")}),r.jsx(S,{label:"On Movie Start",value:String(o("on_movie_start","")),onChange:c=>t("on_movie_start",c),placeholder:"Command to run when recording starts",helpText:"Executed when video recording begins",error:n?.("on_movie_start")}),r.jsx(S,{label:"On Movie End",value:String(o("on_movie_end","")),onChange:c=>t("on_movie_end",c),placeholder:"Command to run when recording ends",helpText:"Executed when video recording is complete (%f = filename)",error:n?.("on_movie_end")}),r.jsxs("div",{className:"mt-6 p-4 bg-surface rounded-lg",children:[r.jsx("h4",{className:"font-medium mb-2 text-sm",children:"Example: Send Picture via Telegram"}),r.jsx("pre",{className:"text-xs text-gray-400 overflow-x-auto whitespace-pre-wrap",children:`# On Picture Save: +curl -F chat_id="YOUR_CHAT_ID" \\ + -F photo=@"%f" \\ + -F caption="Motion on %t at %T" \\ + "https://api.telegram.org/botYOUR_TOKEN/sendPhoto"`})]})]})}const ie={rclone:{label:"Rclone",description:"Universal cloud storage sync (supports 40+ providers)",pictureCmd:'rclone copy "%f" remote:motion/pictures/%Y%m%d/',movieCmd:'rclone copy "%f" remote:motion/movies/%Y%m%d/'},s3:{label:"AWS S3",description:"Amazon S3 or compatible storage (MinIO, DigitalOcean Spaces)",pictureCmd:'aws s3 cp "%f" s3://YOUR_BUCKET/motion/pictures/%Y%m%d/',movieCmd:'aws s3 cp "%f" s3://YOUR_BUCKET/motion/movies/%Y%m%d/'},gdrive:{label:"Google Drive",description:"Upload via gdrive CLI",pictureCmd:'gdrive files upload --parent YOUR_FOLDER_ID "%f"',movieCmd:'gdrive files upload --parent YOUR_FOLDER_ID "%f"'},dropbox:{label:"Dropbox",description:"Upload via Dropbox-Uploader script",pictureCmd:'dropbox_uploader.sh upload "%f" /motion/pictures/',movieCmd:'dropbox_uploader.sh upload "%f" /motion/movies/'},sftp:{label:"SFTP/SCP",description:"Upload to remote server via SSH",pictureCmd:'scp "%f" user@server:/backup/motion/pictures/',movieCmd:'scp "%f" user@server:/backup/motion/movies/'},custom:{label:"Custom",description:"Enter your own upload command",pictureCmd:"",movieCmd:""}};function uc({config:e,onChange:t,getError:n}){const[s,a]=x.useState("rclone"),o=(u,d="")=>e[u]?.value??d,i=String(o("on_picture_save","")),c=String(o("on_movie_end","")),l=()=>{const u=ie[s];u.pictureCmd&&t("on_picture_save",u.pictureCmd),u.movieCmd&&t("on_movie_end",u.movieCmd)};return r.jsxs(O,{title:"Cloud Upload",description:"Configure automatic upload of pictures and videos to cloud storage. Uses the same event hooks as notifications.",collapsible:!0,defaultOpen:!1,children:[r.jsxs("div",{className:"bg-surface rounded-lg p-4 mb-6 text-sm",children:[r.jsxs("p",{className:"text-gray-400 mb-2",children:["Cloud uploads are triggered by the ",r.jsx("code",{className:"bg-surface-elevated px-1 rounded",children:"on_picture_save"})," and ",r.jsx("code",{className:"bg-surface-elevated px-1 rounded",children:"on_movie_end"})," event hooks. Select a provider template below or enter a custom command."]}),r.jsxs("p",{className:"text-xs text-gray-500",children:[r.jsx("strong",{children:"Note:"})," You must install the required CLI tools (rclone, aws-cli, etc.) on your Pi before uploads will work."]})]}),r.jsxs("div",{className:"mb-6 p-4 bg-surface-elevated rounded-lg",children:[r.jsx("h4",{className:"font-medium mb-3 text-sm",children:"Cloud Provider Templates"}),r.jsx("div",{className:"flex flex-wrap gap-2 mb-4",children:Object.keys(ie).map(u=>r.jsx("button",{onClick:()=>a(u),className:`px-3 py-1.5 text-sm rounded transition-colors ${s===u?"bg-primary text-white":"bg-surface hover:bg-surface-elevated"}`,children:ie[u].label},u))}),s!=="custom"&&r.jsxs("div",{className:"text-xs text-gray-400 mb-4",children:[r.jsx("p",{className:"mb-2",children:ie[s].description}),r.jsxs("div",{className:"space-y-2",children:[r.jsxs("div",{children:[r.jsx("span",{className:"text-gray-500",children:"Picture:"}),r.jsx("pre",{className:"mt-1 p-2 bg-surface rounded overflow-x-auto whitespace-pre-wrap break-all",children:ie[s].pictureCmd})]}),r.jsxs("div",{children:[r.jsx("span",{className:"text-gray-500",children:"Movie:"}),r.jsx("pre",{className:"mt-1 p-2 bg-surface rounded overflow-x-auto whitespace-pre-wrap break-all",children:ie[s].movieCmd})]})]})]}),r.jsx("button",{onClick:l,disabled:s==="custom",className:"px-4 py-1.5 text-sm bg-primary hover:bg-primary-hover rounded transition-colors disabled:opacity-50",children:"Apply Template"})]}),r.jsx(S,{label:"Picture Upload Command",value:i,onChange:u=>t("on_picture_save",u),placeholder:"Command to upload pictures",helpText:"Runs after each picture is saved. %f = filename, %Y%m%d = date",error:n?.("on_picture_save")}),r.jsx(S,{label:"Movie Upload Command",value:c,onChange:u=>t("on_movie_end",u),placeholder:"Command to upload videos",helpText:"Runs after each video recording completes. %f = filename",error:n?.("on_movie_end")}),r.jsxs("div",{className:"mt-6 p-4 bg-surface rounded-lg",children:[r.jsx("h4",{className:"font-medium mb-3 text-sm",children:"Quick Setup Guides"}),r.jsxs("div",{className:"space-y-4 text-xs text-gray-400",children:[r.jsxs("div",{children:[r.jsx("h5",{className:"font-medium text-gray-300 mb-1",children:"Rclone Setup"}),r.jsx("pre",{className:"p-2 bg-surface-elevated rounded overflow-x-auto",children:`# Install rclone +sudo apt install rclone + +# Configure a remote (interactive) +rclone config + +# Test upload +rclone copy /path/to/file remote:folder/`})]}),r.jsxs("div",{children:[r.jsx("h5",{className:"font-medium text-gray-300 mb-1",children:"AWS S3 Setup"}),r.jsx("pre",{className:"p-2 bg-surface-elevated rounded overflow-x-auto",children:`# Install AWS CLI +sudo apt install awscli + +# Configure credentials +aws configure + +# Test upload +aws s3 cp /path/to/file s3://bucket/`})]})]})]})]})}function hc(){const{role:e}=Nr(),{addToast:t}=Ye(),[n,s]=x.useState("0"),[a,o]=x.useState({}),[i,c]=x.useState({}),[l,u]=x.useState(!1),d=Zt(),{data:m,isLoading:p,error:f}=qe({queryKey:["config"],queryFn:async()=>{const C=await Lt("/0/api/config");return C.csrf_token&&kr(C.csrf_token),C}}),y=Sr(),{data:g}=Ft(),{data:w}=Hr(Number(n)),_=lr(Number(n));x.useEffect(()=>{o({}),c({})},[n]);const v=x.useCallback((C,z)=>{o(F=>({...F,[C]:z}));const b=mi(C,String(z));c(F=>{if(b.success){const{[C]:Q,...J}=F;return J}else return{...F,[C]:b.error??"Invalid value"}})},[]),$=Object.keys(a).length>0,B=Object.keys(i).length>0,Z=x.useMemo(()=>{if(!m)return{};const C=m.configuration.default||{};if(n==="0")return C;{const z=m.configuration[`cam${n}`]||{};return{...C,...z}}},[m,n]),D=x.useMemo(()=>{if(!m)return{};const C={...Z};for(const[z,b]of Object.entries(a))C[z]?C[z]={...C[z],value:b}:C[z]={value:b,enabled:!0,category:0,type:"string"};return C},[m,Z,a]),W=async()=>{if(!$){t("No changes to save","info");return}if(B){t("Please fix validation errors before saving","error");return}u(!0);const C=parseInt(n,10);try{const z=await y.mutateAsync({camId:C,changes:a});await d.invalidateQueries({queryKey:["config"]}),await d.refetchQueries({queryKey:["config"]});const b=z?.summary,F=z?.applied||[];if(!b)t(`Saved ${Object.keys(a).length} setting(s)`,"success"),o({});else{const Q=F.filter(J=>!J.error&&J.hot_reload===!1).map(J=>J.param);if(Q.length>0){t(`Restarting camera to apply ${Q.length} setting(s): ${Q.join(", ")}...`,"info"),o({});const J=await Cr(C);Br(C),window.dispatchEvent(new CustomEvent(Wr,{detail:{cameraId:C}})),await d.invalidateQueries({queryKey:["config"]}),await d.refetchQueries({queryKey:["config"]}),J?t(`Applied ${Q.length} setting(s). Camera restarted successfully.`,"success"):t("Settings saved. Camera is restarting - refresh page if stream doesn't recover.","warning")}else if(b.errors>0){const J=F.filter(G=>G.error).map(G=>G.param),Oe=F.filter(G=>!G.error).map(G=>G.param),me={};for(const[G,he]of Object.entries(a))Oe.includes(G)||(me[G]=he);o(me),b.success>0?t(`Saved ${b.success} setting(s). ${b.errors} failed: ${J.join(", ")}`,"warning"):t(`Failed to save settings: ${J.join(", ")}`,"error")}else t(`Successfully saved ${b.success} setting(s)`,"success"),o({})}}catch(z){console.error("Failed to save settings:",z),t("Failed to save settings. Check browser console for details.","error")}finally{u(!1)}},de=()=>{o({}),c({}),t("Changes discarded","info")},T=C=>i[C];return e!=="admin"?r.jsx("div",{className:"p-4 sm:p-6",children:r.jsxs("div",{className:"bg-surface-elevated rounded-lg p-8 text-center max-w-2xl mx-auto",children:[r.jsx("svg",{className:"w-16 h-16 mx-auto text-yellow-500 mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:r.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1.5,d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"})}),r.jsx("h1",{className:"text-2xl font-bold mb-2",children:"Admin Access Required"}),r.jsx("p",{className:"text-gray-400",children:"You must be logged in as an administrator to access settings."})]})}):p?r.jsxs("div",{className:"p-6",children:[r.jsx("h2",{className:"text-3xl font-bold mb-6",children:"Settings"}),r.jsxs("div",{className:"animate-pulse",children:[r.jsx("div",{className:"h-32 bg-surface-elevated rounded-lg mb-4"}),r.jsx("div",{className:"h-32 bg-surface-elevated rounded-lg mb-4"})]})]}):f?r.jsxs("div",{className:"p-6",children:[r.jsx("h2",{className:"text-3xl font-bold mb-6",children:"Settings"}),r.jsx("div",{className:"bg-danger/10 border border-danger rounded-lg p-4",children:r.jsx("p",{className:"text-danger",children:"Failed to load configuration"})})]}):m?r.jsxs("div",{className:"p-6",children:[r.jsx("div",{className:"sticky top-[73px] z-40 -mx-6 px-6 py-3 bg-surface/95 backdrop-blur border-b border-gray-800 mb-6",children:r.jsxs("div",{className:"flex items-center justify-between",children:[r.jsxs("div",{className:"flex items-center gap-4",children:[r.jsx("h2",{className:"text-2xl font-bold",children:"Settings"}),r.jsx("div",{children:r.jsxs("select",{value:n,onChange:C=>s(C.target.value),className:"px-3 py-1.5 bg-surface-elevated border border-gray-700 rounded-lg text-sm",children:[r.jsx("option",{value:"0",children:"Global Settings"}),m.cameras&&Object.entries(m.cameras).map(([C,z])=>{if(C==="count"||typeof z=="number")return null;const b=z;return r.jsx("option",{value:String(b.id),children:b.name||`Camera ${b.id}`},b.id)})]})})]}),r.jsxs("div",{className:"flex items-center gap-3",children:[$&&!B&&r.jsx("span",{className:"text-yellow-200 text-sm",children:"Unsaved changes"}),B&&r.jsx("span",{className:"text-red-200 text-sm",children:"Fix errors below"}),$&&r.jsx("button",{onClick:de,disabled:l,className:"px-3 py-1.5 text-sm bg-surface-elevated hover:bg-surface rounded-lg transition-colors disabled:opacity-50",children:"Discard"}),r.jsx("button",{onClick:W,disabled:!$||l||B,className:"px-4 py-1.5 text-sm bg-primary hover:bg-primary-hover rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed",children:l?"Saving...":$?"Save Changes":"Saved"})]})]})}),n==="0"&&r.jsxs(r.Fragment,{children:[r.jsx("div",{className:"bg-blue-500/10 border border-blue-500/30 rounded-lg p-4 mb-6",children:r.jsx("p",{className:"text-sm text-blue-200",children:"Global settings apply to the Motion daemon and web server. To configure camera-specific settings, select a camera from the dropdown above."})}),r.jsx(gi,{config:D,onChange:v,getError:T,originalConfig:Z,systemStatus:g}),r.jsx(O,{title:"Camera Management",description:"Add, remove, and configure cameras",collapsible:!0,defaultOpen:!0,children:r.jsx(_i,{})}),r.jsx(ac,{}),r.jsx(O,{title:"About",description:"Motion version information",collapsible:!0,defaultOpen:!1,children:r.jsxs("p",{className:"text-sm text-gray-400",children:["Motion Version: ",m.version]})})]}),n!=="0"&&r.jsxs(r.Fragment,{children:[r.jsx("div",{className:"bg-surface-elevated rounded-lg p-4 mb-6",children:r.jsx(Vr,{cameraId:Number(n),readOnly:!1})}),r.jsx(ji,{cameraId:Number(n)}),_.features.hasLibcamControls&&r.jsx(Si,{config:D,onChange:v,getError:T,capabilities:w,originalConfig:Z}),_.features.hasV4L2Controls&&r.jsx(Ci,{config:D,onChange:v,controls:_.v4l2Controls,getError:T}),_.features.hasNetcamConfig&&r.jsx($i,{config:D,onChange:v,connectionStatus:_.netcamStatus,hasDualStream:_.features.hasDualStream,getError:T}),r.jsx(xi,{config:D,onChange:v,getError:T}),r.jsx(Zi,{config:D,onChange:v,getError:T,showPassthrough:_.features.supportsPassthrough}),r.jsx(Ii,{config:D,onChange:v,getError:T}),r.jsx(Di,{config:D,onChange:v,getError:T}),r.jsx(Ai,{config:D,onChange:v,getError:T}),r.jsx(cc,{cameraId:parseInt(n,10)}),r.jsx(nc,{config:D,onChange:v,getError:T}),r.jsx(Li,{config:D,onChange:v,getError:T,originalConfig:Z}),r.jsx(Mi,{config:D,onChange:v,getError:T}),r.jsx(lc,{config:D,onChange:v,getError:T}),r.jsx(uc,{config:D,onChange:v,getError:T}),r.jsx(ic,{})]})]}):null}export{hc as Settings}; diff --git a/data/webui/assets/index-DEo73YRp.js b/data/webui/assets/index-DEo73YRp.js new file mode 100644 index 00000000..b4c04b69 --- /dev/null +++ b/data/webui/assets/index-DEo73YRp.js @@ -0,0 +1,17 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Dashboard-C1886l1P.js","assets/parameterMappings-Bp9bMVsO.js","assets/Settings-CrTNsWoa.js"])))=>i.map(i=>d[i]); +(function(){const s=document.createElement("link").relList;if(s&&s.supports&&s.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))r(d);new MutationObserver(d=>{for(const h of d)if(h.type==="childList")for(const m of h.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&r(m)}).observe(document,{childList:!0,subtree:!0});function c(d){const h={};return d.integrity&&(h.integrity=d.integrity),d.referrerPolicy&&(h.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?h.credentials="include":d.crossOrigin==="anonymous"?h.credentials="omit":h.credentials="same-origin",h}function r(d){if(d.ep)return;d.ep=!0;const h=c(d);fetch(d.href,h)}})();function hm(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var lr={exports:{}},Fn={};var Bd;function E0(){if(Bd)return Fn;Bd=1;var n=Symbol.for("react.transitional.element"),s=Symbol.for("react.fragment");function c(r,d,h){var m=null;if(h!==void 0&&(m=""+h),d.key!==void 0&&(m=""+d.key),"key"in d){h={};for(var v in d)v!=="key"&&(h[v]=d[v])}else h=d;return d=h.ref,{$$typeof:n,type:r,key:m,ref:d!==void 0?d:null,props:h}}return Fn.Fragment=s,Fn.jsx=c,Fn.jsxs=c,Fn}var Qd;function T0(){return Qd||(Qd=1,lr.exports=E0()),lr.exports}var _=T0(),ar={exports:{}},P={};var Ld;function x0(){if(Ld)return P;Ld=1;var n=Symbol.for("react.transitional.element"),s=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),h=Symbol.for("react.consumer"),m=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),T=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),D=Symbol.iterator;function q(b){return b===null||typeof b!="object"?null:(b=D&&b[D]||b["@@iterator"],typeof b=="function"?b:null)}var H={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},B=Object.assign,Y={};function Q(b,w,G){this.props=b,this.context=w,this.refs=Y,this.updater=G||H}Q.prototype.isReactComponent={},Q.prototype.setState=function(b,w){if(typeof b!="object"&&typeof b!="function"&&b!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,b,w,"setState")},Q.prototype.forceUpdate=function(b){this.updater.enqueueForceUpdate(this,b,"forceUpdate")};function X(){}X.prototype=Q.prototype;function J(b,w,G){this.props=b,this.context=w,this.refs=Y,this.updater=G||H}var ht=J.prototype=new X;ht.constructor=J,B(ht,Q.prototype),ht.isPureReactComponent=!0;var ct=Array.isArray;function Rt(){}var F={H:null,A:null,T:null,S:null},rt=Object.prototype.hasOwnProperty;function zt(b,w,G){var Z=G.ref;return{$$typeof:n,type:b,key:w,ref:Z!==void 0?Z:null,props:G}}function Lt(b,w){return zt(b.type,w,b.props)}function Wt(b){return typeof b=="object"&&b!==null&&b.$$typeof===n}function ee(b){var w={"=":"=0",":":"=2"};return"$"+b.replace(/[=:]/g,function(G){return w[G]})}var Gl=/\/+/g;function Ve(b,w){return typeof b=="object"&&b!==null&&b.key!=null?ee(""+b.key):w.toString(36)}function Ne(b){switch(b.status){case"fulfilled":return b.value;case"rejected":throw b.reason;default:switch(typeof b.status=="string"?b.then(Rt,Rt):(b.status="pending",b.then(function(w){b.status==="pending"&&(b.status="fulfilled",b.value=w)},function(w){b.status==="pending"&&(b.status="rejected",b.reason=w)})),b.status){case"fulfilled":return b.value;case"rejected":throw b.reason}}throw b}function U(b,w,G,Z,I){var lt=typeof b;(lt==="undefined"||lt==="boolean")&&(b=null);var yt=!1;if(b===null)yt=!0;else switch(lt){case"bigint":case"string":case"number":yt=!0;break;case"object":switch(b.$$typeof){case n:case s:yt=!0;break;case T:return yt=b._init,U(yt(b._payload),w,G,Z,I)}}if(yt)return I=I(b),yt=Z===""?"."+Ve(b,0):Z,ct(I)?(G="",yt!=null&&(G=yt.replace(Gl,"$&/")+"/"),U(I,w,G,"",function(en){return en})):I!=null&&(Wt(I)&&(I=Lt(I,G+(I.key==null||b&&b.key===I.key?"":(""+I.key).replace(Gl,"$&/")+"/")+yt)),w.push(I)),1;yt=0;var Pt=Z===""?".":Z+":";if(ct(b))for(var Nt=0;Nt>>1,Tt=U[gt];if(0>>1;gtd(G,W))Zd(I,G)?(U[gt]=I,U[Z]=W,gt=Z):(U[gt]=G,U[w]=W,gt=w);else if(Zd(I,W))U[gt]=I,U[Z]=W,gt=Z;else break t}}return L}function d(U,L){var W=U.sortIndex-L.sortIndex;return W!==0?W:U.id-L.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var h=performance;n.unstable_now=function(){return h.now()}}else{var m=Date,v=m.now();n.unstable_now=function(){return m.now()-v}}var p=[],y=[],T=1,x=null,D=3,q=!1,H=!1,B=!1,Y=!1,Q=typeof setTimeout=="function"?setTimeout:null,X=typeof clearTimeout=="function"?clearTimeout:null,J=typeof setImmediate<"u"?setImmediate:null;function ht(U){for(var L=c(y);L!==null;){if(L.callback===null)r(y);else if(L.startTime<=U)r(y),L.sortIndex=L.expirationTime,s(p,L);else break;L=c(y)}}function ct(U){if(B=!1,ht(U),!H)if(c(p)!==null)H=!0,Rt||(Rt=!0,ee());else{var L=c(y);L!==null&&Ne(ct,L.startTime-U)}}var Rt=!1,F=-1,rt=5,zt=-1;function Lt(){return Y?!0:!(n.unstable_now()-ztU&&Lt());){var gt=x.callback;if(typeof gt=="function"){x.callback=null,D=x.priorityLevel;var Tt=gt(x.expirationTime<=U);if(U=n.unstable_now(),typeof Tt=="function"){x.callback=Tt,ht(U),L=!0;break e}x===c(p)&&r(p),ht(U)}else r(p);x=c(p)}if(x!==null)L=!0;else{var b=c(y);b!==null&&Ne(ct,b.startTime-U),L=!1}}break t}finally{x=null,D=W,q=!1}L=void 0}}finally{L?ee():Rt=!1}}}var ee;if(typeof J=="function")ee=function(){J(Wt)};else if(typeof MessageChannel<"u"){var Gl=new MessageChannel,Ve=Gl.port2;Gl.port1.onmessage=Wt,ee=function(){Ve.postMessage(null)}}else ee=function(){Q(Wt,0)};function Ne(U,L){F=Q(function(){U(n.unstable_now())},L)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(U){U.callback=null},n.unstable_forceFrameRate=function(U){0>U||125gt?(U.sortIndex=W,s(y,U),c(p)===null&&U===c(y)&&(B?(X(F),F=-1):B=!0,Ne(ct,W-gt))):(U.sortIndex=Tt,s(p,U),H||q||(H=!0,Rt||(Rt=!0,ee()))),U},n.unstable_shouldYield=Lt,n.unstable_wrapCallback=function(U){var L=D;return function(){var W=D;D=L;try{return U.apply(this,arguments)}finally{D=W}}}})(ir)),ir}var Xd;function A0(){return Xd||(Xd=1,ur.exports=O0()),ur.exports}var sr={exports:{}},$t={};var Kd;function C0(){if(Kd)return $t;Kd=1;var n=br();function s(p){var y="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(s){console.error(s)}}return n(),sr.exports=C0(),sr.exports}var Vd;function z0(){if(Vd)return $n;Vd=1;var n=A0(),s=br(),c=M0();function r(t){var e="https://react.dev/errors/"+t;if(1Tt||(t.current=gt[Tt],gt[Tt]=null,Tt--)}function G(t,e){Tt++,gt[Tt]=t.current,t.current=e}var Z=b(null),I=b(null),lt=b(null),yt=b(null);function Pt(t,e){switch(G(lt,e),G(I,t),G(Z,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?sd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=sd(e),t=cd(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}w(Z),G(Z,t)}function Nt(){w(Z),w(I),w(lt)}function en(t){t.memoizedState!==null&&G(yt,t);var e=Z.current,l=cd(e,t.type);e!==l&&(G(I,t),G(Z,l))}function iu(t){I.current===t&&(w(Z),w(I)),yt.current===t&&(w(yt),Zn._currentValue=W)}var Bi,wr;function Xl(t){if(Bi===void 0)try{throw Error()}catch(l){var e=l.stack.trim().match(/\n( *(at )?)/);Bi=e&&e[1]||"",wr=-1)":-1u||g[a]!==A[u]){var z=` +`+g[a].replace(" at new "," at ");return t.displayName&&z.includes("")&&(z=z.replace("",t.displayName)),z}while(1<=a&&0<=u);break}}}finally{Qi=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?Xl(l):""}function Pm(t,e){switch(t.tag){case 26:case 27:case 5:return Xl(t.type);case 16:return Xl("Lazy");case 13:return t.child!==e&&e!==null?Xl("Suspense Fallback"):Xl("Suspense");case 19:return Xl("SuspenseList");case 0:case 15:return Li(t.type,!1);case 11:return Li(t.type.render,!1);case 1:return Li(t.type,!0);case 31:return Xl("Activity");default:return""}}function qr(t){try{var e="",l=null;do e+=Pm(t,l),l=t,t=t.return;while(t);return e}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Yi=Object.prototype.hasOwnProperty,Gi=n.unstable_scheduleCallback,Xi=n.unstable_cancelCallback,Im=n.unstable_shouldYield,ty=n.unstable_requestPaint,re=n.unstable_now,ey=n.unstable_getCurrentPriorityLevel,Hr=n.unstable_ImmediatePriority,Br=n.unstable_UserBlockingPriority,su=n.unstable_NormalPriority,ly=n.unstable_LowPriority,Qr=n.unstable_IdlePriority,ay=n.log,ny=n.unstable_setDisableYieldValue,ln=null,fe=null;function yl(t){if(typeof ay=="function"&&ny(t),fe&&typeof fe.setStrictMode=="function")try{fe.setStrictMode(ln,t)}catch{}}var oe=Math.clz32?Math.clz32:sy,uy=Math.log,iy=Math.LN2;function sy(t){return t>>>=0,t===0?32:31-(uy(t)/iy|0)|0}var cu=256,ru=262144,fu=4194304;function Kl(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function ou(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var u=0,i=t.suspendedLanes,f=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~i,a!==0?u=Kl(a):(f&=o,f!==0?u=Kl(f):l||(l=o&~t,l!==0&&(u=Kl(l))))):(o=a&~i,o!==0?u=Kl(o):f!==0?u=Kl(f):l||(l=a&~t,l!==0&&(u=Kl(l)))),u===0?0:e!==0&&e!==u&&(e&i)===0&&(i=u&-u,l=e&-e,i>=l||i===32&&(l&4194048)!==0)?e:u}function an(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function cy(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Lr(){var t=fu;return fu<<=1,(fu&62914560)===0&&(fu=4194304),t}function Ki(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function nn(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function ry(t,e,l,a,u,i){var f=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,g=t.expirationTimes,A=t.hiddenUpdates;for(l=f&~l;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var yy=/[\n"\\]/g;function Te(t){return t.replace(yy,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function $i(t,e,l,a,u,i,f,o){t.name="",f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?t.type=f:t.removeAttribute("type"),e!=null?f==="number"?(e===0&&t.value===""||t.value!=e)&&(t.value=""+Ee(e)):t.value!==""+Ee(e)&&(t.value=""+Ee(e)):f!=="submit"&&f!=="reset"||t.removeAttribute("value"),e!=null?Wi(t,f,Ee(e)):l!=null?Wi(t,f,Ee(l)):a!=null&&t.removeAttribute("value"),u==null&&i!=null&&(t.defaultChecked=!!i),u!=null&&(t.checked=u&&typeof u!="function"&&typeof u!="symbol"),o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?t.name=""+Ee(o):t.removeAttribute("name")}function Ir(t,e,l,a,u,i,f,o){if(i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.type=i),e!=null||l!=null){if(!(i!=="submit"&&i!=="reset"||e!=null)){Fi(t);return}l=l!=null?""+Ee(l):"",e=e!=null?""+Ee(e):l,o||e===t.value||(t.value=e),t.defaultValue=e}a=a??u,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=o?t.checked:!!a,t.defaultChecked=!!a,f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"&&(t.name=f),Fi(t)}function Wi(t,e,l){e==="number"&&mu(t.ownerDocument)===t||t.defaultValue===""+l||(t.defaultValue=""+l)}function ga(t,e,l,a){if(t=t.options,e){e={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ls=!1;if(Fe)try{var rn={};Object.defineProperty(rn,"passive",{get:function(){ls=!0}}),window.addEventListener("test",rn,rn),window.removeEventListener("test",rn,rn)}catch{ls=!1}var pl=null,as=null,vu=null;function sf(){if(vu)return vu;var t,e=as,l=e.length,a,u="value"in pl?pl.value:pl.textContent,i=u.length;for(t=0;t=hn),df=" ",mf=!1;function yf(t,e){switch(t){case"keyup":return Xy.indexOf(e.keyCode)!==-1;case"keydown":return e.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function vf(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Ta=!1;function Zy(t,e){switch(t){case"compositionend":return vf(e);case"keypress":return e.which!==32?null:(mf=!0,df);case"textInput":return t=e.data,t===df&&mf?null:t;default:return null}}function Vy(t,e){if(Ta)return t==="compositionend"||!cs&&yf(t,e)?(t=sf(),vu=as=pl=null,Ta=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(e.ctrlKey||e.altKey||e.metaKey)||e.ctrlKey&&e.altKey){if(e.char&&1=e)return{node:l,offset:e-t};t=a}t:{for(;l;){if(l.nextSibling){l=l.nextSibling;break t}l=l.parentNode}l=void 0}l=Rf(l)}}function Af(t,e){return t&&e?t===e?!0:t&&t.nodeType===3?!1:e&&e.nodeType===3?Af(t,e.parentNode):"contains"in t?t.contains(e):t.compareDocumentPosition?!!(t.compareDocumentPosition(e)&16):!1:!1}function Cf(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var e=mu(t.document);e instanceof t.HTMLIFrameElement;){try{var l=typeof e.contentWindow.location.href=="string"}catch{l=!1}if(l)t=e.contentWindow;else break;e=mu(t.document)}return e}function os(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&(e==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||e==="textarea"||t.contentEditable==="true")}var tv=Fe&&"documentMode"in document&&11>=document.documentMode,xa=null,hs=null,vn=null,ds=!1;function Mf(t,e,l){var a=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;ds||xa==null||xa!==mu(a)||(a=xa,"selectionStart"in a&&os(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),vn&&yn(vn,a)||(vn=a,a=ri(hs,"onSelect"),0>=f,u-=f,Be=1<<32-oe(e)+u|l<et?(it=V,V=null):it=V.sibling;var ot=C(E,V,O[et],N);if(ot===null){V===null&&(V=it);break}t&&V&&ot.alternate===null&&e(E,V),S=i(ot,S,et),ft===null?k=ot:ft.sibling=ot,ft=ot,V=it}if(et===O.length)return l(E,V),st&&We(E,et),k;if(V===null){for(;etet?(it=V,V=null):it=V.sibling;var Ql=C(E,V,ot.value,N);if(Ql===null){V===null&&(V=it);break}t&&V&&Ql.alternate===null&&e(E,V),S=i(Ql,S,et),ft===null?k=Ql:ft.sibling=Ql,ft=Ql,V=it}if(ot.done)return l(E,V),st&&We(E,et),k;if(V===null){for(;!ot.done;et++,ot=O.next())ot=j(E,ot.value,N),ot!==null&&(S=i(ot,S,et),ft===null?k=ot:ft.sibling=ot,ft=ot);return st&&We(E,et),k}for(V=a(V);!ot.done;et++,ot=O.next())ot=M(V,E,et,ot.value,N),ot!==null&&(t&&ot.alternate!==null&&V.delete(ot.key===null?et:ot.key),S=i(ot,S,et),ft===null?k=ot:ft.sibling=ot,ft=ot);return t&&V.forEach(function(b0){return e(E,b0)}),st&&We(E,et),k}function Et(E,S,O,N){if(typeof O=="object"&&O!==null&&O.type===B&&O.key===null&&(O=O.props.children),typeof O=="object"&&O!==null){switch(O.$$typeof){case q:t:{for(var k=O.key;S!==null;){if(S.key===k){if(k=O.type,k===B){if(S.tag===7){l(E,S.sibling),N=u(S,O.props.children),N.return=E,E=N;break t}}else if(S.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===rt&&ea(k)===S.type){l(E,S.sibling),N=u(S,O.props),Tn(N,O),N.return=E,E=N;break t}l(E,S);break}else e(E,S);S=S.sibling}O.type===B?(N=$l(O.props.children,E.mode,N,O.key),N.return=E,E=N):(N=Au(O.type,O.key,O.props,null,E.mode,N),Tn(N,O),N.return=E,E=N)}return f(E);case H:t:{for(k=O.key;S!==null;){if(S.key===k)if(S.tag===4&&S.stateNode.containerInfo===O.containerInfo&&S.stateNode.implementation===O.implementation){l(E,S.sibling),N=u(S,O.children||[]),N.return=E,E=N;break t}else{l(E,S);break}else e(E,S);S=S.sibling}N=bs(O,E.mode,N),N.return=E,E=N}return f(E);case rt:return O=ea(O),Et(E,S,O,N)}if(Ne(O))return K(E,S,O,N);if(ee(O)){if(k=ee(O),typeof k!="function")throw Error(r(150));return O=k.call(O),$(E,S,O,N)}if(typeof O.then=="function")return Et(E,S,Nu(O),N);if(O.$$typeof===J)return Et(E,S,zu(E,O),N);ju(E,O)}return typeof O=="string"&&O!==""||typeof O=="number"||typeof O=="bigint"?(O=""+O,S!==null&&S.tag===6?(l(E,S.sibling),N=u(S,O),N.return=E,E=N):(l(E,S),N=Ss(O,E.mode,N),N.return=E,E=N),f(E)):l(E,S)}return function(E,S,O,N){try{En=0;var k=Et(E,S,O,N);return ja=null,k}catch(V){if(V===Na||V===Du)throw V;var ft=de(29,V,null,E.mode);return ft.lanes=N,ft.return=E,ft}}}var aa=Wf(!0),Pf=Wf(!1),Tl=!1;function Us(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ns(t,e){t=t.updateQueue,e.updateQueue===t&&(e.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function xl(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function Rl(t,e,l){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(dt&2)!==0){var u=a.pending;return u===null?e.next=e:(e.next=u.next,u.next=e),a.pending=e,e=Ou(t),wf(t,null,l),e}return Ru(t,a,e,l),Ou(t)}function xn(t,e,l){if(e=e.updateQueue,e!==null&&(e=e.shared,(l&4194048)!==0)){var a=e.lanes;a&=t.pendingLanes,l|=a,e.lanes=l,Gr(t,l)}}function js(t,e){var l=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,l===a)){var u=null,i=null;if(l=l.firstBaseUpdate,l!==null){do{var f={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};i===null?u=i=f:i=i.next=f,l=l.next}while(l!==null);i===null?u=i=e:i=i.next=e}else u=i=e;l={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:i,shared:a.shared,callbacks:a.callbacks},t.updateQueue=l;return}t=l.lastBaseUpdate,t===null?l.firstBaseUpdate=e:t.next=e,l.lastBaseUpdate=e}var ws=!1;function Rn(){if(ws){var t=Ua;if(t!==null)throw t}}function On(t,e,l,a){ws=!1;var u=t.updateQueue;Tl=!1;var i=u.firstBaseUpdate,f=u.lastBaseUpdate,o=u.shared.pending;if(o!==null){u.shared.pending=null;var g=o,A=g.next;g.next=null,f===null?i=A:f.next=A,f=g;var z=t.alternate;z!==null&&(z=z.updateQueue,o=z.lastBaseUpdate,o!==f&&(o===null?z.firstBaseUpdate=A:o.next=A,z.lastBaseUpdate=g))}if(i!==null){var j=u.baseState;f=0,z=A=g=null,o=i;do{var C=o.lane&-536870913,M=C!==o.lane;if(M?(ut&C)===C:(a&C)===C){C!==0&&C===Da&&(ws=!0),z!==null&&(z=z.next={lane:0,tag:o.tag,payload:o.payload,callback:null,next:null});t:{var K=t,$=o;C=e;var Et=l;switch($.tag){case 1:if(K=$.payload,typeof K=="function"){j=K.call(Et,j,C);break t}j=K;break t;case 3:K.flags=K.flags&-65537|128;case 0:if(K=$.payload,C=typeof K=="function"?K.call(Et,j,C):K,C==null)break t;j=x({},j,C);break t;case 2:Tl=!0}}C=o.callback,C!==null&&(t.flags|=64,M&&(t.flags|=8192),M=u.callbacks,M===null?u.callbacks=[C]:M.push(C))}else M={lane:C,tag:o.tag,payload:o.payload,callback:o.callback,next:null},z===null?(A=z=M,g=j):z=z.next=M,f|=C;if(o=o.next,o===null){if(o=u.shared.pending,o===null)break;M=o,o=M.next,M.next=null,u.lastBaseUpdate=M,u.shared.pending=null}}while(!0);z===null&&(g=j),u.baseState=g,u.firstBaseUpdate=A,u.lastBaseUpdate=z,i===null&&(u.shared.lanes=0),zl|=f,t.lanes=f,t.memoizedState=j}}function If(t,e){if(typeof t!="function")throw Error(r(191,t));t.call(e)}function to(t,e){var l=t.callbacks;if(l!==null)for(t.callbacks=null,t=0;ti?i:8;var f=U.T,o={};U.T=o,tc(t,!1,e,l);try{var g=u(),A=U.S;if(A!==null&&A(o,g),g!==null&&typeof g=="object"&&typeof g.then=="function"){var z=rv(g,a);Mn(t,e,z,ge(t))}else Mn(t,e,a,ge(t))}catch(j){Mn(t,e,{then:function(){},status:"rejected",reason:j},ge())}finally{L.p=i,f!==null&&o.types!==null&&(f.types=o.types),U.T=f}}function yv(){}function Ps(t,e,l,a){if(t.tag!==5)throw Error(r(476));var u=No(t).queue;Uo(t,u,e,W,l===null?yv:function(){return jo(t),l(a)})}function No(t){var e=t.memoizedState;if(e!==null)return e;e={memoizedState:W,baseState:W,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:el,lastRenderedState:W},next:null};var l={};return e.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:el,lastRenderedState:l},next:null},t.memoizedState=e,t=t.alternate,t!==null&&(t.memoizedState=e),e}function jo(t){var e=No(t);e.next===null&&(e=t.alternate.memoizedState),Mn(t,e.next.queue,{},ge())}function Is(){return Jt(Zn)}function wo(){return wt().memoizedState}function qo(){return wt().memoizedState}function vv(t){for(var e=t.return;e!==null;){switch(e.tag){case 24:case 3:var l=ge();t=xl(l);var a=Rl(e,t,l);a!==null&&(ce(a,e,l),xn(a,e,l)),e={cache:Ms()},t.payload=e;return}e=e.return}}function pv(t,e,l){var a=ge();l={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},Ku(t)?Bo(e,l):(l=ps(t,e,l,a),l!==null&&(ce(l,t,a),Qo(l,e,a)))}function Ho(t,e,l){var a=ge();Mn(t,e,l,a)}function Mn(t,e,l,a){var u={lane:a,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(Ku(t))Bo(e,u);else{var i=t.alternate;if(t.lanes===0&&(i===null||i.lanes===0)&&(i=e.lastRenderedReducer,i!==null))try{var f=e.lastRenderedState,o=i(f,l);if(u.hasEagerState=!0,u.eagerState=o,he(o,f))return Ru(t,e,u,0),xt===null&&xu(),!1}catch{}if(l=ps(t,e,u,a),l!==null)return ce(l,t,a),Qo(l,e,a),!0}return!1}function tc(t,e,l,a){if(a={lane:2,revertLane:Uc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},Ku(t)){if(e)throw Error(r(479))}else e=ps(t,l,a,2),e!==null&&ce(e,t,2)}function Ku(t){var e=t.alternate;return t===tt||e!==null&&e===tt}function Bo(t,e){qa=Hu=!0;var l=t.pending;l===null?e.next=e:(e.next=l.next,l.next=e),t.pending=e}function Qo(t,e,l){if((l&4194048)!==0){var a=e.lanes;a&=t.pendingLanes,l|=a,e.lanes=l,Gr(t,l)}}var zn={readContext:Jt,use:Lu,useCallback:_t,useContext:_t,useEffect:_t,useImperativeHandle:_t,useLayoutEffect:_t,useInsertionEffect:_t,useMemo:_t,useReducer:_t,useRef:_t,useState:_t,useDebugValue:_t,useDeferredValue:_t,useTransition:_t,useSyncExternalStore:_t,useId:_t,useHostTransitionStatus:_t,useFormState:_t,useActionState:_t,useOptimistic:_t,useMemoCache:_t,useCacheRefresh:_t};zn.useEffectEvent=_t;var Lo={readContext:Jt,use:Lu,useCallback:function(t,e){return It().memoizedState=[t,e===void 0?null:e],t},useContext:Jt,useEffect:xo,useImperativeHandle:function(t,e,l){l=l!=null?l.concat([t]):null,Gu(4194308,4,Co.bind(null,e,t),l)},useLayoutEffect:function(t,e){return Gu(4194308,4,t,e)},useInsertionEffect:function(t,e){Gu(4,2,t,e)},useMemo:function(t,e){var l=It();e=e===void 0?null:e;var a=t();if(na){yl(!0);try{t()}finally{yl(!1)}}return l.memoizedState=[a,e],a},useReducer:function(t,e,l){var a=It();if(l!==void 0){var u=l(e);if(na){yl(!0);try{l(e)}finally{yl(!1)}}}else u=e;return a.memoizedState=a.baseState=u,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:u},a.queue=t,t=t.dispatch=pv.bind(null,tt,t),[a.memoizedState,t]},useRef:function(t){var e=It();return t={current:t},e.memoizedState=t},useState:function(t){t=Js(t);var e=t.queue,l=Ho.bind(null,tt,e);return e.dispatch=l,[t.memoizedState,l]},useDebugValue:$s,useDeferredValue:function(t,e){var l=It();return Ws(l,t,e)},useTransition:function(){var t=Js(!1);return t=Uo.bind(null,tt,t.queue,!0,!1),It().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,e,l){var a=tt,u=It();if(st){if(l===void 0)throw Error(r(407));l=l()}else{if(l=e(),xt===null)throw Error(r(349));(ut&127)!==0||io(a,e,l)}u.memoizedState=l;var i={value:l,getSnapshot:e};return u.queue=i,xo(co.bind(null,a,i,t),[t]),a.flags|=2048,Ba(9,{destroy:void 0},so.bind(null,a,i,l,e),null),l},useId:function(){var t=It(),e=xt.identifierPrefix;if(st){var l=Qe,a=Be;l=(a&~(1<<32-oe(a)-1)).toString(32)+l,e="_"+e+"R_"+l,l=Bu++,0<\/script>",i=i.removeChild(i.firstChild);break;case"select":i=typeof a.is=="string"?f.createElement("select",{is:a.is}):f.createElement("select"),a.multiple?i.multiple=!0:a.size&&(i.size=a.size);break;default:i=typeof a.is=="string"?f.createElement(u,{is:a.is}):f.createElement(u)}}i[Zt]=e,i[le]=a;t:for(f=e.child;f!==null;){if(f.tag===5||f.tag===6)i.appendChild(f.stateNode);else if(f.tag!==4&&f.tag!==27&&f.child!==null){f.child.return=f,f=f.child;continue}if(f===e)break t;for(;f.sibling===null;){if(f.return===null||f.return===e)break t;f=f.return}f.sibling.return=f.return,f=f.sibling}e.stateNode=i;t:switch(Ft(i,u,a),u){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&al(e)}}return At(e),mc(e,e.type,t===null?null:t.memoizedProps,e.pendingProps,l),null;case 6:if(t&&e.stateNode!=null)t.memoizedProps!==a&&al(e);else{if(typeof a!="string"&&e.stateNode===null)throw Error(r(166));if(t=lt.current,za(e)){if(t=e.stateNode,l=e.memoizedProps,a=null,u=Vt,u!==null)switch(u.tag){case 27:case 5:a=u.memoizedProps}t[Zt]=e,t=!!(t.nodeValue===l||a!==null&&a.suppressHydrationWarning===!0||ud(t.nodeValue,l)),t||bl(e,!0)}else t=fi(t).createTextNode(a),t[Zt]=e,e.stateNode=t}return At(e),null;case 31:if(l=e.memoizedState,t===null||t.memoizedState!==null){if(a=za(e),l!==null){if(t===null){if(!a)throw Error(r(318));if(t=e.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(r(557));t[Zt]=e}else Wl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;At(e),t=!1}else l=Rs(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=l),t=!0;if(!t)return e.flags&256?(ye(e),e):(ye(e),null);if((e.flags&128)!==0)throw Error(r(558))}return At(e),null;case 13:if(a=e.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(u=za(e),a!==null&&a.dehydrated!==null){if(t===null){if(!u)throw Error(r(318));if(u=e.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(r(317));u[Zt]=e}else Wl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;At(e),u=!1}else u=Rs(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=u),u=!0;if(!u)return e.flags&256?(ye(e),e):(ye(e),null)}return ye(e),(e.flags&128)!==0?(e.lanes=l,e):(l=a!==null,t=t!==null&&t.memoizedState!==null,l&&(a=e.child,u=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(u=a.alternate.memoizedState.cachePool.pool),i=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(i=a.memoizedState.cachePool.pool),i!==u&&(a.flags|=2048)),l!==t&&l&&(e.child.flags|=8192),Fu(e,e.updateQueue),At(e),null);case 4:return Nt(),t===null&&qc(e.stateNode.containerInfo),At(e),null;case 10:return Ie(e.type),At(e),null;case 19:if(w(jt),a=e.memoizedState,a===null)return At(e),null;if(u=(e.flags&128)!==0,i=a.rendering,i===null)if(u)Dn(a,!1);else{if(Dt!==0||t!==null&&(t.flags&128)!==0)for(t=e.child;t!==null;){if(i=qu(t),i!==null){for(e.flags|=128,Dn(a,!1),t=i.updateQueue,e.updateQueue=t,Fu(e,t),e.subtreeFlags=0,t=l,l=e.child;l!==null;)qf(l,t),l=l.sibling;return G(jt,jt.current&1|2),st&&We(e,a.treeForkCount),e.child}t=t.sibling}a.tail!==null&&re()>ti&&(e.flags|=128,u=!0,Dn(a,!1),e.lanes=4194304)}else{if(!u)if(t=qu(i),t!==null){if(e.flags|=128,u=!0,t=t.updateQueue,e.updateQueue=t,Fu(e,t),Dn(a,!0),a.tail===null&&a.tailMode==="hidden"&&!i.alternate&&!st)return At(e),null}else 2*re()-a.renderingStartTime>ti&&l!==536870912&&(e.flags|=128,u=!0,Dn(a,!1),e.lanes=4194304);a.isBackwards?(i.sibling=e.child,e.child=i):(t=a.last,t!==null?t.sibling=i:e.child=i,a.last=i)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=re(),t.sibling=null,l=jt.current,G(jt,u?l&1|2:l&1),st&&We(e,a.treeForkCount),t):(At(e),null);case 22:case 23:return ye(e),Hs(),a=e.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(e.flags|=8192):a&&(e.flags|=8192),a?(l&536870912)!==0&&(e.flags&128)===0&&(At(e),e.subtreeFlags&6&&(e.flags|=8192)):At(e),l=e.updateQueue,l!==null&&Fu(e,l.retryQueue),l=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(l=t.memoizedState.cachePool.pool),a=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(a=e.memoizedState.cachePool.pool),a!==l&&(e.flags|=2048),t!==null&&w(ta),null;case 24:return l=null,t!==null&&(l=t.memoizedState.cache),e.memoizedState.cache!==l&&(e.flags|=2048),Ie(qt),At(e),null;case 25:return null;case 30:return null}throw Error(r(156,e.tag))}function Tv(t,e){switch(Ts(e),e.tag){case 1:return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 3:return Ie(qt),Nt(),t=e.flags,(t&65536)!==0&&(t&128)===0?(e.flags=t&-65537|128,e):null;case 26:case 27:case 5:return iu(e),null;case 31:if(e.memoizedState!==null){if(ye(e),e.alternate===null)throw Error(r(340));Wl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 13:if(ye(e),t=e.memoizedState,t!==null&&t.dehydrated!==null){if(e.alternate===null)throw Error(r(340));Wl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 19:return w(jt),null;case 4:return Nt(),null;case 10:return Ie(e.type),null;case 22:case 23:return ye(e),Hs(),t!==null&&w(ta),t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 24:return Ie(qt),null;case 25:return null;default:return null}}function fh(t,e){switch(Ts(e),e.tag){case 3:Ie(qt),Nt();break;case 26:case 27:case 5:iu(e);break;case 4:Nt();break;case 31:e.memoizedState!==null&&ye(e);break;case 13:ye(e);break;case 19:w(jt);break;case 10:Ie(e.type);break;case 22:case 23:ye(e),Hs(),t!==null&&w(ta);break;case 24:Ie(qt)}}function Un(t,e){try{var l=e.updateQueue,a=l!==null?l.lastEffect:null;if(a!==null){var u=a.next;l=u;do{if((l.tag&t)===t){a=void 0;var i=l.create,f=l.inst;a=i(),f.destroy=a}l=l.next}while(l!==u)}}catch(o){pt(e,e.return,o)}}function Cl(t,e,l){try{var a=e.updateQueue,u=a!==null?a.lastEffect:null;if(u!==null){var i=u.next;a=i;do{if((a.tag&t)===t){var f=a.inst,o=f.destroy;if(o!==void 0){f.destroy=void 0,u=e;var g=l,A=o;try{A()}catch(z){pt(u,g,z)}}}a=a.next}while(a!==i)}}catch(z){pt(e,e.return,z)}}function oh(t){var e=t.updateQueue;if(e!==null){var l=t.stateNode;try{to(e,l)}catch(a){pt(t,t.return,a)}}}function hh(t,e,l){l.props=ua(t.type,t.memoizedProps),l.state=t.memoizedState;try{l.componentWillUnmount()}catch(a){pt(t,e,a)}}function Nn(t,e){try{var l=t.ref;if(l!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof l=="function"?t.refCleanup=l(a):l.current=a}}catch(u){pt(t,e,u)}}function Le(t,e){var l=t.ref,a=t.refCleanup;if(l!==null)if(typeof a=="function")try{a()}catch(u){pt(t,e,u)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(u){pt(t,e,u)}else l.current=null}function dh(t){var e=t.type,l=t.memoizedProps,a=t.stateNode;try{t:switch(e){case"button":case"input":case"select":case"textarea":l.autoFocus&&a.focus();break t;case"img":l.src?a.src=l.src:l.srcSet&&(a.srcset=l.srcSet)}}catch(u){pt(t,t.return,u)}}function yc(t,e,l){try{var a=t.stateNode;Kv(a,t.type,l,e),a[le]=e}catch(u){pt(t,t.return,u)}}function mh(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&jl(t.type)||t.tag===4}function vc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||mh(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&jl(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function pc(t,e,l){var a=t.tag;if(a===5||a===6)t=t.stateNode,e?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(t,e):(e=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,e.appendChild(t),l=l._reactRootContainer,l!=null||e.onclick!==null||(e.onclick=ke));else if(a!==4&&(a===27&&jl(t.type)&&(l=t.stateNode,e=null),t=t.child,t!==null))for(pc(t,e,l),t=t.sibling;t!==null;)pc(t,e,l),t=t.sibling}function $u(t,e,l){var a=t.tag;if(a===5||a===6)t=t.stateNode,e?l.insertBefore(t,e):l.appendChild(t);else if(a!==4&&(a===27&&jl(t.type)&&(l=t.stateNode),t=t.child,t!==null))for($u(t,e,l),t=t.sibling;t!==null;)$u(t,e,l),t=t.sibling}function yh(t){var e=t.stateNode,l=t.memoizedProps;try{for(var a=t.type,u=e.attributes;u.length;)e.removeAttributeNode(u[0]);Ft(e,a,l),e[Zt]=t,e[le]=l}catch(i){pt(t,t.return,i)}}var nl=!1,Qt=!1,gc=!1,vh=typeof WeakSet=="function"?WeakSet:Set,Kt=null;function xv(t,e){if(t=t.containerInfo,Qc=pi,t=Cf(t),os(t)){if("selectionStart"in t)var l={start:t.selectionStart,end:t.selectionEnd};else t:{l=(l=t.ownerDocument)&&l.defaultView||window;var a=l.getSelection&&l.getSelection();if(a&&a.rangeCount!==0){l=a.anchorNode;var u=a.anchorOffset,i=a.focusNode;a=a.focusOffset;try{l.nodeType,i.nodeType}catch{l=null;break t}var f=0,o=-1,g=-1,A=0,z=0,j=t,C=null;e:for(;;){for(var M;j!==l||u!==0&&j.nodeType!==3||(o=f+u),j!==i||a!==0&&j.nodeType!==3||(g=f+a),j.nodeType===3&&(f+=j.nodeValue.length),(M=j.firstChild)!==null;)C=j,j=M;for(;;){if(j===t)break e;if(C===l&&++A===u&&(o=f),C===i&&++z===a&&(g=f),(M=j.nextSibling)!==null)break;j=C,C=j.parentNode}j=M}l=o===-1||g===-1?null:{start:o,end:g}}else l=null}l=l||{start:0,end:0}}else l=null;for(Lc={focusedElem:t,selectionRange:l},pi=!1,Kt=e;Kt!==null;)if(e=Kt,t=e.child,(e.subtreeFlags&1028)!==0&&t!==null)t.return=e,Kt=t;else for(;Kt!==null;){switch(e=Kt,i=e.alternate,t=e.flags,e.tag){case 0:if((t&4)!==0&&(t=e.updateQueue,t=t!==null?t.events:null,t!==null))for(l=0;l title"))),Ft(i,a,l),i[Zt]=t,Xt(i),a=i;break t;case"link":var f=Td("link","href",u).get(a+(l.href||""));if(f){for(var o=0;oEt&&(f=Et,Et=$,$=f);var E=Of(o,$),S=Of(o,Et);if(E&&S&&(M.rangeCount!==1||M.anchorNode!==E.node||M.anchorOffset!==E.offset||M.focusNode!==S.node||M.focusOffset!==S.offset)){var O=j.createRange();O.setStart(E.node,E.offset),M.removeAllRanges(),$>Et?(M.addRange(O),M.extend(S.node,S.offset)):(O.setEnd(S.node,S.offset),M.addRange(O))}}}}for(j=[],M=o;M=M.parentNode;)M.nodeType===1&&j.push({element:M,left:M.scrollLeft,top:M.scrollTop});for(typeof o.focus=="function"&&o.focus(),o=0;ol?32:l,U.T=null,l=Oc,Oc=null;var i=Dl,f=rl;if(Yt=0,Xa=Dl=null,rl=0,(dt&6)!==0)throw Error(r(331));var o=dt;if(dt|=4,Ch(i.current),Rh(i,i.current,f,l),dt=o,Qn(0,!1),fe&&typeof fe.onPostCommitFiberRoot=="function")try{fe.onPostCommitFiberRoot(ln,i)}catch{}return!0}finally{L.p=u,U.T=a,Zh(t,e)}}function Jh(t,e,l){e=Re(l,e),e=nc(t.stateNode,e,2),t=Rl(t,e,2),t!==null&&(nn(t,2),Ye(t))}function pt(t,e,l){if(t.tag===3)Jh(t,t,l);else for(;e!==null;){if(e.tag===3){Jh(e,t,l);break}else if(e.tag===1){var a=e.stateNode;if(typeof e.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(_l===null||!_l.has(a))){t=Re(l,t),l=ko(2),a=Rl(e,l,2),a!==null&&(Fo(l,a,e,t),nn(a,2),Ye(a));break}}e=e.return}}function zc(t,e,l){var a=t.pingCache;if(a===null){a=t.pingCache=new Av;var u=new Set;a.set(e,u)}else u=a.get(e),u===void 0&&(u=new Set,a.set(e,u));u.has(l)||(Ec=!0,u.add(l),t=Dv.bind(null,t,e,l),e.then(t,t))}function Dv(t,e,l){var a=t.pingCache;a!==null&&a.delete(e),t.pingedLanes|=t.suspendedLanes&l,t.warmLanes&=~l,xt===t&&(ut&l)===l&&(Dt===4||Dt===3&&(ut&62914560)===ut&&300>re()-Iu?(dt&2)===0&&Ka(t,0):Tc|=l,Ga===ut&&(Ga=0)),Ye(t)}function kh(t,e){e===0&&(e=Lr()),t=Fl(t,e),t!==null&&(nn(t,e),Ye(t))}function Uv(t){var e=t.memoizedState,l=0;e!==null&&(l=e.retryLane),kh(t,l)}function Nv(t,e){var l=0;switch(t.tag){case 31:case 13:var a=t.stateNode,u=t.memoizedState;u!==null&&(l=u.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(r(314))}a!==null&&a.delete(e),kh(t,l)}function jv(t,e){return Gi(t,e)}var ii=null,Va=null,_c=!1,si=!1,Dc=!1,Nl=0;function Ye(t){t!==Va&&t.next===null&&(Va===null?ii=Va=t:Va=Va.next=t),si=!0,_c||(_c=!0,qv())}function Qn(t,e){if(!Dc&&si){Dc=!0;do for(var l=!1,a=ii;a!==null;){if(t!==0){var u=a.pendingLanes;if(u===0)var i=0;else{var f=a.suspendedLanes,o=a.pingedLanes;i=(1<<31-oe(42|t)+1)-1,i&=u&~(f&~o),i=i&201326741?i&201326741|1:i?i|2:0}i!==0&&(l=!0,Ph(a,i))}else i=ut,i=ou(a,a===xt?i:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(i&3)===0||an(a,i)||(l=!0,Ph(a,i));a=a.next}while(l);Dc=!1}}function wv(){Fh()}function Fh(){si=_c=!1;var t=0;Nl!==0&&Vv()&&(t=Nl);for(var e=re(),l=null,a=ii;a!==null;){var u=a.next,i=$h(a,e);i===0?(a.next=null,l===null?ii=u:l.next=u,u===null&&(Va=l)):(l=a,(t!==0||(i&3)!==0)&&(si=!0)),a=u}Yt!==0&&Yt!==5||Qn(t),Nl!==0&&(Nl=0)}function $h(t,e){for(var l=t.suspendedLanes,a=t.pingedLanes,u=t.expirationTimes,i=t.pendingLanes&-62914561;0o)break;var z=g.transferSize,j=g.initiatorType;z&&id(j)&&(g=g.responseEnd,f+=z*(g"u"?null:document;function gd(t,e,l){var a=Ja;if(a&&typeof e=="string"&&e){var u=Te(e);u='link[rel="'+t+'"][href="'+u+'"]',typeof l=="string"&&(u+='[crossorigin="'+l+'"]'),pd.has(u)||(pd.add(u),t={rel:t,crossOrigin:l,href:e},a.querySelector(u)===null&&(e=a.createElement("link"),Ft(e,"link",t),Xt(e),a.head.appendChild(e)))}}function e0(t){fl.D(t),gd("dns-prefetch",t,null)}function l0(t,e){fl.C(t,e),gd("preconnect",t,e)}function a0(t,e,l){fl.L(t,e,l);var a=Ja;if(a&&t&&e){var u='link[rel="preload"][as="'+Te(e)+'"]';e==="image"&&l&&l.imageSrcSet?(u+='[imagesrcset="'+Te(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(u+='[imagesizes="'+Te(l.imageSizes)+'"]')):u+='[href="'+Te(t)+'"]';var i=u;switch(e){case"style":i=ka(t);break;case"script":i=Fa(t)}_e.has(i)||(t=x({rel:"preload",href:e==="image"&&l&&l.imageSrcSet?void 0:t,as:e},l),_e.set(i,t),a.querySelector(u)!==null||e==="style"&&a.querySelector(Xn(i))||e==="script"&&a.querySelector(Kn(i))||(e=a.createElement("link"),Ft(e,"link",t),Xt(e),a.head.appendChild(e)))}}function n0(t,e){fl.m(t,e);var l=Ja;if(l&&t){var a=e&&typeof e.as=="string"?e.as:"script",u='link[rel="modulepreload"][as="'+Te(a)+'"][href="'+Te(t)+'"]',i=u;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":i=Fa(t)}if(!_e.has(i)&&(t=x({rel:"modulepreload",href:t},e),_e.set(i,t),l.querySelector(u)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(Kn(i)))return}a=l.createElement("link"),Ft(a,"link",t),Xt(a),l.head.appendChild(a)}}}function u0(t,e,l){fl.S(t,e,l);var a=Ja;if(a&&t){var u=va(a).hoistableStyles,i=ka(t);e=e||"default";var f=u.get(i);if(!f){var o={loading:0,preload:null};if(f=a.querySelector(Xn(i)))o.loading=5;else{t=x({rel:"stylesheet",href:t,"data-precedence":e},l),(l=_e.get(i))&&Jc(t,l);var g=f=a.createElement("link");Xt(g),Ft(g,"link",t),g._p=new Promise(function(A,z){g.onload=A,g.onerror=z}),g.addEventListener("load",function(){o.loading|=1}),g.addEventListener("error",function(){o.loading|=2}),o.loading|=4,hi(f,e,a)}f={type:"stylesheet",instance:f,count:1,state:o},u.set(i,f)}}}function i0(t,e){fl.X(t,e);var l=Ja;if(l&&t){var a=va(l).hoistableScripts,u=Fa(t),i=a.get(u);i||(i=l.querySelector(Kn(u)),i||(t=x({src:t,async:!0},e),(e=_e.get(u))&&kc(t,e),i=l.createElement("script"),Xt(i),Ft(i,"link",t),l.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},a.set(u,i))}}function s0(t,e){fl.M(t,e);var l=Ja;if(l&&t){var a=va(l).hoistableScripts,u=Fa(t),i=a.get(u);i||(i=l.querySelector(Kn(u)),i||(t=x({src:t,async:!0,type:"module"},e),(e=_e.get(u))&&kc(t,e),i=l.createElement("script"),Xt(i),Ft(i,"link",t),l.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},a.set(u,i))}}function Sd(t,e,l,a){var u=(u=lt.current)?oi(u):null;if(!u)throw Error(r(446));switch(t){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(e=ka(l.href),l=va(u).hoistableStyles,a=l.get(e),a||(a={type:"style",instance:null,count:0,state:null},l.set(e,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){t=ka(l.href);var i=va(u).hoistableStyles,f=i.get(t);if(f||(u=u.ownerDocument||u,f={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},i.set(t,f),(i=u.querySelector(Xn(t)))&&!i._p&&(f.instance=i,f.state.loading=5),_e.has(t)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},_e.set(t,l),i||c0(u,t,l,f.state))),e&&a===null)throw Error(r(528,""));return f}if(e&&a!==null)throw Error(r(529,""));return null;case"script":return e=l.async,l=l.src,typeof l=="string"&&e&&typeof e!="function"&&typeof e!="symbol"?(e=Fa(l),l=va(u).hoistableScripts,a=l.get(e),a||(a={type:"script",instance:null,count:0,state:null},l.set(e,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,t))}}function ka(t){return'href="'+Te(t)+'"'}function Xn(t){return'link[rel="stylesheet"]['+t+"]"}function bd(t){return x({},t,{"data-precedence":t.precedence,precedence:null})}function c0(t,e,l,a){t.querySelector('link[rel="preload"][as="style"]['+e+"]")?a.loading=1:(e=t.createElement("link"),a.preload=e,e.addEventListener("load",function(){return a.loading|=1}),e.addEventListener("error",function(){return a.loading|=2}),Ft(e,"link",l),Xt(e),t.head.appendChild(e))}function Fa(t){return'[src="'+Te(t)+'"]'}function Kn(t){return"script[async]"+t}function Ed(t,e,l){if(e.count++,e.instance===null)switch(e.type){case"style":var a=t.querySelector('style[data-href~="'+Te(l.href)+'"]');if(a)return e.instance=a,Xt(a),a;var u=x({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Xt(a),Ft(a,"style",u),hi(a,l.precedence,t),e.instance=a;case"stylesheet":u=ka(l.href);var i=t.querySelector(Xn(u));if(i)return e.state.loading|=4,e.instance=i,Xt(i),i;a=bd(l),(u=_e.get(u))&&Jc(a,u),i=(t.ownerDocument||t).createElement("link"),Xt(i);var f=i;return f._p=new Promise(function(o,g){f.onload=o,f.onerror=g}),Ft(i,"link",a),e.state.loading|=4,hi(i,l.precedence,t),e.instance=i;case"script":return i=Fa(l.src),(u=t.querySelector(Kn(i)))?(e.instance=u,Xt(u),u):(a=l,(u=_e.get(i))&&(a=x({},l),kc(a,u)),t=t.ownerDocument||t,u=t.createElement("script"),Xt(u),Ft(u,"link",a),t.head.appendChild(u),e.instance=u);case"void":return null;default:throw Error(r(443,e.type))}else e.type==="stylesheet"&&(e.state.loading&4)===0&&(a=e.instance,e.state.loading|=4,hi(a,l.precedence,t));return e.instance}function hi(t,e,l){for(var a=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=a.length?a[a.length-1]:null,i=u,f=0;f title"):null)}function r0(t,e,l){if(l===1||e.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof e.precedence!="string"||typeof e.href!="string"||e.href==="")break;return!0;case"link":if(typeof e.rel!="string"||typeof e.href!="string"||e.href===""||e.onLoad||e.onError)break;return e.rel==="stylesheet"?(t=e.disabled,typeof e.precedence=="string"&&t==null):!0;case"script":if(e.async&&typeof e.async!="function"&&typeof e.async!="symbol"&&!e.onLoad&&!e.onError&&e.src&&typeof e.src=="string")return!0}return!1}function Rd(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function f0(t,e,l,a){if(l.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var u=ka(a.href),i=e.querySelector(Xn(u));if(i){e=i._p,e!==null&&typeof e=="object"&&typeof e.then=="function"&&(t.count++,t=mi.bind(t),e.then(t,t)),l.state.loading|=4,l.instance=i,Xt(i);return}i=e.ownerDocument||e,a=bd(a),(u=_e.get(u))&&Jc(a,u),i=i.createElement("link"),Xt(i);var f=i;f._p=new Promise(function(o,g){f.onload=o,f.onerror=g}),Ft(i,"link",a),l.instance=i}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(l,e),(e=l.state.preload)&&(l.state.loading&3)===0&&(t.count++,l=mi.bind(t),e.addEventListener("load",l),e.addEventListener("error",l))}}var Fc=0;function o0(t,e){return t.stylesheets&&t.count===0&&vi(t,t.stylesheets),0Fc?50:800)+e);return t.unsuspend=l,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(u)}}:null}function mi(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)vi(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var yi=null;function vi(t,e){t.stylesheets=null,t.unsuspend!==null&&(t.count++,yi=new Map,e.forEach(h0,t),yi=null,mi.call(t))}function h0(t,e){if(!(e.state.loading&4)){var l=yi.get(t);if(l)var a=l.get(null);else{l=new Map,yi.set(t,l);for(var u=t.querySelectorAll("link[data-precedence],style[data-precedence]"),i=0;i"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(s){console.error(s)}}return n(),nr.exports=z0(),nr.exports}var D0=_0();const U0=hm(D0);var kd="popstate";function N0(n={}){function s(r,d){let{pathname:h,search:m,hash:v}=r.location;return hr("",{pathname:h,search:m,hash:v},d.state&&d.state.usr||null,d.state&&d.state.key||"default")}function c(r,d){return typeof d=="string"?d:In(d)}return w0(s,c,null,n)}function Mt(n,s){if(n===!1||n===null||typeof n>"u")throw new Error(s)}function He(n,s){if(!n){typeof console<"u"&&console.warn(s);try{throw new Error(s)}catch{}}}function j0(){return Math.random().toString(36).substring(2,10)}function Fd(n,s){return{usr:n.state,key:n.key,idx:s}}function hr(n,s,c=null,r){return{pathname:typeof n=="string"?n:n.pathname,search:"",hash:"",...typeof s=="string"?Pa(s):s,state:c,key:s&&s.key||r||j0()}}function In({pathname:n="/",search:s="",hash:c=""}){return s&&s!=="?"&&(n+=s.charAt(0)==="?"?s:"?"+s),c&&c!=="#"&&(n+=c.charAt(0)==="#"?c:"#"+c),n}function Pa(n){let s={};if(n){let c=n.indexOf("#");c>=0&&(s.hash=n.substring(c),n=n.substring(0,c));let r=n.indexOf("?");r>=0&&(s.search=n.substring(r),n=n.substring(0,r)),n&&(s.pathname=n)}return s}function w0(n,s,c,r={}){let{window:d=document.defaultView,v5Compat:h=!1}=r,m=d.history,v="POP",p=null,y=T();y==null&&(y=0,m.replaceState({...m.state,idx:y},""));function T(){return(m.state||{idx:null}).idx}function x(){v="POP";let Y=T(),Q=Y==null?null:Y-y;y=Y,p&&p({action:v,location:B.location,delta:Q})}function D(Y,Q){v="PUSH";let X=hr(B.location,Y,Q);y=T()+1;let J=Fd(X,y),ht=B.createHref(X);try{m.pushState(J,"",ht)}catch(ct){if(ct instanceof DOMException&&ct.name==="DataCloneError")throw ct;d.location.assign(ht)}h&&p&&p({action:v,location:B.location,delta:1})}function q(Y,Q){v="REPLACE";let X=hr(B.location,Y,Q);y=T();let J=Fd(X,y),ht=B.createHref(X);m.replaceState(J,"",ht),h&&p&&p({action:v,location:B.location,delta:0})}function H(Y){return q0(Y)}let B={get action(){return v},get location(){return n(d,m)},listen(Y){if(p)throw new Error("A history only accepts one active listener");return d.addEventListener(kd,x),p=Y,()=>{d.removeEventListener(kd,x),p=null}},createHref(Y){return s(d,Y)},createURL:H,encodeLocation(Y){let Q=H(Y);return{pathname:Q.pathname,search:Q.search,hash:Q.hash}},push:D,replace:q,go(Y){return m.go(Y)}};return B}function q0(n,s=!1){let c="http://localhost";typeof window<"u"&&(c=window.location.origin!=="null"?window.location.origin:window.location.href),Mt(c,"No window.location.(origin|href) available to create URL");let r=typeof n=="string"?n:In(n);return r=r.replace(/ $/,"%20"),!s&&r.startsWith("//")&&(r=c+r),new URL(r,c)}function dm(n,s,c="/"){return H0(n,s,c,!1)}function H0(n,s,c,r){let d=typeof s=="string"?Pa(s):s,h=hl(d.pathname||"/",c);if(h==null)return null;let m=mm(n);B0(m);let v=null;for(let p=0;v==null&&p{let T={relativePath:y===void 0?m.path||"":y,caseSensitive:m.caseSensitive===!0,childrenIndex:v,route:m};if(T.relativePath.startsWith("/")){if(!T.relativePath.startsWith(r)&&p)return;Mt(T.relativePath.startsWith(r),`Absolute route path "${T.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),T.relativePath=T.relativePath.slice(r.length)}let x=ol([r,T.relativePath]),D=c.concat(T);m.children&&m.children.length>0&&(Mt(m.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${x}".`),mm(m.children,s,D,x,p)),!(m.path==null&&!m.index)&&s.push({path:x,score:Z0(x,m.index),routesMeta:D})};return n.forEach((m,v)=>{if(m.path===""||!m.path?.includes("?"))h(m,v);else for(let p of ym(m.path))h(m,v,!0,p)}),s}function ym(n){let s=n.split("/");if(s.length===0)return[];let[c,...r]=s,d=c.endsWith("?"),h=c.replace(/\?$/,"");if(r.length===0)return d?[h,""]:[h];let m=ym(r.join("/")),v=[];return v.push(...m.map(p=>p===""?h:[h,p].join("/"))),d&&v.push(...m),v.map(p=>n.startsWith("/")&&p===""?"/":p)}function B0(n){n.sort((s,c)=>s.score!==c.score?c.score-s.score:V0(s.routesMeta.map(r=>r.childrenIndex),c.routesMeta.map(r=>r.childrenIndex)))}var Q0=/^:[\w-]+$/,L0=3,Y0=2,G0=1,X0=10,K0=-2,$d=n=>n==="*";function Z0(n,s){let c=n.split("/"),r=c.length;return c.some($d)&&(r+=K0),s&&(r+=Y0),c.filter(d=>!$d(d)).reduce((d,h)=>d+(Q0.test(h)?L0:h===""?G0:X0),r)}function V0(n,s){return n.length===s.length&&n.slice(0,-1).every((r,d)=>r===s[d])?n[n.length-1]-s[s.length-1]:0}function J0(n,s,c=!1){let{routesMeta:r}=n,d={},h="/",m=[];for(let v=0;v{if(T==="*"){let H=v[D]||"";m=h.slice(0,h.length-H.length).replace(/(.)\/+$/,"$1")}const q=v[D];return x&&!q?y[T]=void 0:y[T]=(q||"").replace(/%2F/g,"/"),y},{}),pathname:h,pathnameBase:m,pattern:n}}function k0(n,s=!1,c=!0){He(n==="*"||!n.endsWith("*")||n.endsWith("/*"),`Route path "${n}" will be treated as if it were "${n.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${n.replace(/\*$/,"/*")}".`);let r=[],d="^"+n.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(m,v,p)=>(r.push({paramName:v,isOptional:p!=null}),p?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return n.endsWith("*")?(r.push({paramName:"*"}),d+=n==="*"||n==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):c?d+="\\/*$":n!==""&&n!=="/"&&(d+="(?:(?=\\/|$))"),[new RegExp(d,s?void 0:"i"),r]}function F0(n){try{return n.split("/").map(s=>decodeURIComponent(s).replace(/\//g,"%2F")).join("/")}catch(s){return He(!1,`The URL path "${n}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${s}).`),n}}function hl(n,s){if(s==="/")return n;if(!n.toLowerCase().startsWith(s.toLowerCase()))return null;let c=s.endsWith("/")?s.length-1:s.length,r=n.charAt(c);return r&&r!=="/"?null:n.slice(c)||"/"}var vm=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,$0=n=>vm.test(n);function W0(n,s="/"){let{pathname:c,search:r="",hash:d=""}=typeof n=="string"?Pa(n):n,h;if(c)if($0(c))h=c;else{if(c.includes("//")){let m=c;c=c.replace(/\/\/+/g,"/"),He(!1,`Pathnames cannot have embedded double slashes - normalizing ${m} -> ${c}`)}c.startsWith("/")?h=Wd(c.substring(1),"/"):h=Wd(c,s)}else h=s;return{pathname:h,search:tp(r),hash:ep(d)}}function Wd(n,s){let c=s.replace(/\/+$/,"").split("/");return n.split("/").forEach(d=>{d===".."?c.length>1&&c.pop():d!=="."&&c.push(d)}),c.length>1?c.join("/"):"/"}function cr(n,s,c,r){return`Cannot include a '${n}' character in a manually specified \`to.${s}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${c}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function P0(n){return n.filter((s,c)=>c===0||s.route.path&&s.route.path.length>0)}function pm(n){let s=P0(n);return s.map((c,r)=>r===s.length-1?c.pathname:c.pathnameBase)}function gm(n,s,c,r=!1){let d;typeof n=="string"?d=Pa(n):(d={...n},Mt(!d.pathname||!d.pathname.includes("?"),cr("?","pathname","search",d)),Mt(!d.pathname||!d.pathname.includes("#"),cr("#","pathname","hash",d)),Mt(!d.search||!d.search.includes("#"),cr("#","search","hash",d)));let h=n===""||d.pathname==="",m=h?"/":d.pathname,v;if(m==null)v=c;else{let x=s.length-1;if(!r&&m.startsWith("..")){let D=m.split("/");for(;D[0]==="..";)D.shift(),x-=1;d.pathname=D.join("/")}v=x>=0?s[x]:"/"}let p=W0(d,v),y=m&&m!=="/"&&m.endsWith("/"),T=(h||m===".")&&c.endsWith("/");return!p.pathname.endsWith("/")&&(y||T)&&(p.pathname+="/"),p}var ol=n=>n.join("/").replace(/\/\/+/g,"/"),I0=n=>n.replace(/\/+$/,"").replace(/^\/*/,"/"),tp=n=>!n||n==="?"?"":n.startsWith("?")?n:"?"+n,ep=n=>!n||n==="#"?"":n.startsWith("#")?n:"#"+n,lp=class{constructor(n,s,c,r=!1){this.status=n,this.statusText=s||"",this.internal=r,c instanceof Error?(this.data=c.toString(),this.error=c):this.data=c}};function ap(n){return n!=null&&typeof n.status=="number"&&typeof n.statusText=="string"&&typeof n.internal=="boolean"&&"data"in n}function np(n){return n.map(s=>s.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var Sm=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function bm(n,s){let c=n;if(typeof c!="string"||!vm.test(c))return{absoluteURL:void 0,isExternal:!1,to:c};let r=c,d=!1;if(Sm)try{let h=new URL(window.location.href),m=c.startsWith("//")?new URL(h.protocol+c):new URL(c),v=hl(m.pathname,s);m.origin===h.origin&&v!=null?c=v+m.search+m.hash:d=!0}catch{He(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:d,to:c}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var Em=["POST","PUT","PATCH","DELETE"];new Set(Em);var up=["GET",...Em];new Set(up);var Ia=R.createContext(null);Ia.displayName="DataRouter";var Di=R.createContext(null);Di.displayName="DataRouterState";var ip=R.createContext(!1),Tm=R.createContext({isTransitioning:!1});Tm.displayName="ViewTransition";var sp=R.createContext(new Map);sp.displayName="Fetchers";var cp=R.createContext(null);cp.displayName="Await";var Ue=R.createContext(null);Ue.displayName="Navigation";var lu=R.createContext(null);lu.displayName="Location";var Xe=R.createContext({outlet:null,matches:[],isDataRoute:!1});Xe.displayName="Route";var Er=R.createContext(null);Er.displayName="RouteError";var xm="REACT_ROUTER_ERROR",rp="REDIRECT",fp="ROUTE_ERROR_RESPONSE";function op(n){if(n.startsWith(`${xm}:${rp}:{`))try{let s=JSON.parse(n.slice(28));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string"&&typeof s.location=="string"&&typeof s.reloadDocument=="boolean"&&typeof s.replace=="boolean")return s}catch{}}function hp(n){if(n.startsWith(`${xm}:${fp}:{`))try{let s=JSON.parse(n.slice(40));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string")return new lp(s.status,s.statusText,s.data)}catch{}}function dp(n,{relative:s}={}){Mt(au(),"useHref() may be used only in the context of a component.");let{basename:c,navigator:r}=R.useContext(Ue),{hash:d,pathname:h,search:m}=nu(n,{relative:s}),v=h;return c!=="/"&&(v=h==="/"?c:ol([c,h])),r.createHref({pathname:v,search:m,hash:d})}function au(){return R.useContext(lu)!=null}function oa(){return Mt(au(),"useLocation() may be used only in the context of a component."),R.useContext(lu).location}var Rm="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Om(n){R.useContext(Ue).static||R.useLayoutEffect(n)}function Am(){let{isDataRoute:n}=R.useContext(Xe);return n?Mp():mp()}function mp(){Mt(au(),"useNavigate() may be used only in the context of a component.");let n=R.useContext(Ia),{basename:s,navigator:c}=R.useContext(Ue),{matches:r}=R.useContext(Xe),{pathname:d}=oa(),h=JSON.stringify(pm(r)),m=R.useRef(!1);return Om(()=>{m.current=!0}),R.useCallback((p,y={})=>{if(He(m.current,Rm),!m.current)return;if(typeof p=="number"){c.go(p);return}let T=gm(p,JSON.parse(h),d,y.relative==="path");n==null&&s!=="/"&&(T.pathname=T.pathname==="/"?s:ol([s,T.pathname])),(y.replace?c.replace:c.push)(T,y.state,y)},[s,c,h,d,n])}var yp=R.createContext(null);function vp(n){let s=R.useContext(Xe).outlet;return R.useMemo(()=>s&&R.createElement(yp.Provider,{value:n},s),[s,n])}function nu(n,{relative:s}={}){let{matches:c}=R.useContext(Xe),{pathname:r}=oa(),d=JSON.stringify(pm(c));return R.useMemo(()=>gm(n,JSON.parse(d),r,s==="path"),[n,d,r,s])}function pp(n,s){return Cm(n,s)}function Cm(n,s,c,r,d){Mt(au(),"useRoutes() may be used only in the context of a component.");let{navigator:h}=R.useContext(Ue),{matches:m}=R.useContext(Xe),v=m[m.length-1],p=v?v.params:{},y=v?v.pathname:"/",T=v?v.pathnameBase:"/",x=v&&v.route;{let X=x&&x.path||"";zm(y,!x||X.endsWith("*")||X.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${y}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let D=oa(),q;if(s){let X=typeof s=="string"?Pa(s):s;Mt(T==="/"||X.pathname?.startsWith(T),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${T}" but pathname "${X.pathname}" was given in the \`location\` prop.`),q=X}else q=D;let H=q.pathname||"/",B=H;if(T!=="/"){let X=T.replace(/^\//,"").split("/");B="/"+H.replace(/^\//,"").split("/").slice(X.length).join("/")}let Y=dm(n,{pathname:B});He(x||Y!=null,`No routes matched location "${q.pathname}${q.search}${q.hash}" `),He(Y==null||Y[Y.length-1].route.element!==void 0||Y[Y.length-1].route.Component!==void 0||Y[Y.length-1].route.lazy!==void 0,`Matched leaf route at location "${q.pathname}${q.search}${q.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let Q=Tp(Y&&Y.map(X=>Object.assign({},X,{params:Object.assign({},p,X.params),pathname:ol([T,h.encodeLocation?h.encodeLocation(X.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:X.pathname]),pathnameBase:X.pathnameBase==="/"?T:ol([T,h.encodeLocation?h.encodeLocation(X.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:X.pathnameBase])})),m,c,r,d);return s&&Q?R.createElement(lu.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...q},navigationType:"POP"}},Q):Q}function gp(){let n=Cp(),s=ap(n)?`${n.status} ${n.statusText}`:n instanceof Error?n.message:JSON.stringify(n),c=n instanceof Error?n.stack:null,r="rgba(200,200,200, 0.5)",d={padding:"0.5rem",backgroundColor:r},h={padding:"2px 4px",backgroundColor:r},m=null;return console.error("Error handled by React Router default ErrorBoundary:",n),m=R.createElement(R.Fragment,null,R.createElement("p",null,"💿 Hey developer 👋"),R.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",R.createElement("code",{style:h},"ErrorBoundary")," or"," ",R.createElement("code",{style:h},"errorElement")," prop on your route.")),R.createElement(R.Fragment,null,R.createElement("h2",null,"Unexpected Application Error!"),R.createElement("h3",{style:{fontStyle:"italic"}},s),c?R.createElement("pre",{style:d},c):null,m)}var Sp=R.createElement(gp,null),Mm=class extends R.Component{constructor(n){super(n),this.state={location:n.location,revalidation:n.revalidation,error:n.error}}static getDerivedStateFromError(n){return{error:n}}static getDerivedStateFromProps(n,s){return s.location!==n.location||s.revalidation!=="idle"&&n.revalidation==="idle"?{error:n.error,location:n.location,revalidation:n.revalidation}:{error:n.error!==void 0?n.error:s.error,location:s.location,revalidation:n.revalidation||s.revalidation}}componentDidCatch(n,s){this.props.onError?this.props.onError(n,s):console.error("React Router caught the following error during render",n)}render(){let n=this.state.error;if(this.context&&typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){const c=hp(n.digest);c&&(n=c)}let s=n!==void 0?R.createElement(Xe.Provider,{value:this.props.routeContext},R.createElement(Er.Provider,{value:n,children:this.props.component})):this.props.children;return this.context?R.createElement(bp,{error:n},s):s}};Mm.contextType=ip;var rr=new WeakMap;function bp({children:n,error:s}){let{basename:c}=R.useContext(Ue);if(typeof s=="object"&&s&&"digest"in s&&typeof s.digest=="string"){let r=op(s.digest);if(r){let d=rr.get(s);if(d)throw d;let h=bm(r.location,c);if(Sm&&!rr.get(s))if(h.isExternal||r.reloadDocument)window.location.href=h.absoluteURL||h.to;else{const m=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(h.to,{replace:r.replace}));throw rr.set(s,m),m}return R.createElement("meta",{httpEquiv:"refresh",content:`0;url=${h.absoluteURL||h.to}`})}}return n}function Ep({routeContext:n,match:s,children:c}){let r=R.useContext(Ia);return r&&r.static&&r.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=s.route.id),R.createElement(Xe.Provider,{value:n},c)}function Tp(n,s=[],c=null,r=null,d=null){if(n==null){if(!c)return null;if(c.errors)n=c.matches;else if(s.length===0&&!c.initialized&&c.matches.length>0)n=c.matches;else return null}let h=n,m=c?.errors;if(m!=null){let T=h.findIndex(x=>x.route.id&&m?.[x.route.id]!==void 0);Mt(T>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(m).join(",")}`),h=h.slice(0,Math.min(h.length,T+1))}let v=!1,p=-1;if(c)for(let T=0;T=0?h=h.slice(0,p+1):h=[h[0]];break}}}let y=c&&r?(T,x)=>{r(T,{location:c.location,params:c.matches?.[0]?.params??{},unstable_pattern:np(c.matches),errorInfo:x})}:void 0;return h.reduceRight((T,x,D)=>{let q,H=!1,B=null,Y=null;c&&(q=m&&x.route.id?m[x.route.id]:void 0,B=x.route.errorElement||Sp,v&&(p<0&&D===0?(zm("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),H=!0,Y=null):p===D&&(H=!0,Y=x.route.hydrateFallbackElement||null)));let Q=s.concat(h.slice(0,D+1)),X=()=>{let J;return q?J=B:H?J=Y:x.route.Component?J=R.createElement(x.route.Component,null):x.route.element?J=x.route.element:J=T,R.createElement(Ep,{match:x,routeContext:{outlet:T,matches:Q,isDataRoute:c!=null},children:J})};return c&&(x.route.ErrorBoundary||x.route.errorElement||D===0)?R.createElement(Mm,{location:c.location,revalidation:c.revalidation,component:B,error:q,children:X(),routeContext:{outlet:null,matches:Q,isDataRoute:!0},onError:y}):X()},null)}function Tr(n){return`${n} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function xp(n){let s=R.useContext(Ia);return Mt(s,Tr(n)),s}function Rp(n){let s=R.useContext(Di);return Mt(s,Tr(n)),s}function Op(n){let s=R.useContext(Xe);return Mt(s,Tr(n)),s}function xr(n){let s=Op(n),c=s.matches[s.matches.length-1];return Mt(c.route.id,`${n} can only be used on routes that contain a unique "id"`),c.route.id}function Ap(){return xr("useRouteId")}function Cp(){let n=R.useContext(Er),s=Rp("useRouteError"),c=xr("useRouteError");return n!==void 0?n:s.errors?.[c]}function Mp(){let{router:n}=xp("useNavigate"),s=xr("useNavigate"),c=R.useRef(!1);return Om(()=>{c.current=!0}),R.useCallback(async(d,h={})=>{He(c.current,Rm),c.current&&(typeof d=="number"?await n.navigate(d):await n.navigate(d,{fromRouteId:s,...h}))},[n,s])}var Pd={};function zm(n,s,c){!s&&!Pd[n]&&(Pd[n]=!0,He(!1,c))}R.memo(zp);function zp({routes:n,future:s,state:c,onError:r}){return Cm(n,void 0,c,r,s)}function _p(n){return vp(n.context)}function Pn(n){Mt(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Dp({basename:n="/",children:s=null,location:c,navigationType:r="POP",navigator:d,static:h=!1,unstable_useTransitions:m}){Mt(!au(),"You cannot render a inside another . You should never have more than one in your app.");let v=n.replace(/^\/*/,"/"),p=R.useMemo(()=>({basename:v,navigator:d,static:h,unstable_useTransitions:m,future:{}}),[v,d,h,m]);typeof c=="string"&&(c=Pa(c));let{pathname:y="/",search:T="",hash:x="",state:D=null,key:q="default"}=c,H=R.useMemo(()=>{let B=hl(y,v);return B==null?null:{location:{pathname:B,search:T,hash:x,state:D,key:q},navigationType:r}},[v,y,T,x,D,q,r]);return He(H!=null,` is not able to match the URL "${y}${T}${x}" because it does not start with the basename, so the won't render anything.`),H==null?null:R.createElement(Ue.Provider,{value:p},R.createElement(lu.Provider,{children:s,value:H}))}function Up({children:n,location:s}){return pp(dr(n),s)}function dr(n,s=[]){let c=[];return R.Children.forEach(n,(r,d)=>{if(!R.isValidElement(r))return;let h=[...s,d];if(r.type===R.Fragment){c.push.apply(c,dr(r.props.children,h));return}Mt(r.type===Pn,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Mt(!r.props.index||!r.props.children,"An index route cannot have child routes.");let m={id:r.props.id||h.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(m.children=dr(r.props.children,h)),c.push(m)}),c}var Ai="get",Ci="application/x-www-form-urlencoded";function Ui(n){return typeof HTMLElement<"u"&&n instanceof HTMLElement}function Np(n){return Ui(n)&&n.tagName.toLowerCase()==="button"}function jp(n){return Ui(n)&&n.tagName.toLowerCase()==="form"}function wp(n){return Ui(n)&&n.tagName.toLowerCase()==="input"}function qp(n){return!!(n.metaKey||n.altKey||n.ctrlKey||n.shiftKey)}function Hp(n,s){return n.button===0&&(!s||s==="_self")&&!qp(n)}var Ri=null;function Bp(){if(Ri===null)try{new FormData(document.createElement("form"),0),Ri=!1}catch{Ri=!0}return Ri}var Qp=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function fr(n){return n!=null&&!Qp.has(n)?(He(!1,`"${n}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Ci}"`),null):n}function Lp(n,s){let c,r,d,h,m;if(jp(n)){let v=n.getAttribute("action");r=v?hl(v,s):null,c=n.getAttribute("method")||Ai,d=fr(n.getAttribute("enctype"))||Ci,h=new FormData(n)}else if(Np(n)||wp(n)&&(n.type==="submit"||n.type==="image")){let v=n.form;if(v==null)throw new Error('Cannot submit a + + + + {/* Content */} +
+ {children} +
+ + + ) +} diff --git a/frontend/src/components/CameraStream.tsx b/frontend/src/components/CameraStream.tsx new file mode 100644 index 00000000..63abd7df --- /dev/null +++ b/frontend/src/components/CameraStream.tsx @@ -0,0 +1,146 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useMjpegStream } from '@/hooks/useMjpegStream' +import { getStoredRestartTimestamp } from '@/lib/cameraRestart' + +interface CameraStreamProps { + cameraId: number + className?: string + onStreamFpsChange?: (fps: number) => void // Callback for streaming FPS updates +} + +// Custom event name for camera restart notification +export const CAMERA_RESTARTED_EVENT = 'camera-restarted' + +export function CameraStream({ cameraId, className = '', onStreamFpsChange }: CameraStreamProps) { + // Track the last known restart timestamp to detect changes + const lastKnownRestartRef = useRef(getStoredRestartTimestamp(cameraId)) + + // Initialize streamKey from stored restart timestamp + // This ensures fresh connections after navigation back from Settings + const [streamKey, setStreamKey] = useState(() => getStoredRestartTimestamp(cameraId)) + + // Use MJPEG parser for client-side frame counting + const { imageUrl, streamFps, isConnected, error } = useMjpegStream(cameraId, streamKey) + + // Store callback in ref to avoid triggering effect when callback reference changes + // This prevents render loops when parent passes inline arrow functions + const onStreamFpsChangeRef = useRef(onStreamFpsChange) + + // Update ref whenever callback changes (synchronous to avoid stale closures) + useEffect(() => { + onStreamFpsChangeRef.current = onStreamFpsChange + }) + + // Notify parent of streaming FPS changes - only triggers when fps actually changes + useEffect(() => { + if (onStreamFpsChangeRef.current) { + onStreamFpsChangeRef.current(streamFps) + } + }, [streamFps]) // ← Only streamFps in deps, NOT onStreamFpsChange + + // Force stream reconnection by changing key + const handleReconnect = useCallback(() => { + // Add small delay before retry to avoid hammering + setTimeout(() => { + setStreamKey((k) => k + 1) + }, 2000) + }, []) + + // Listen for camera restart events to force reconnection (same-page scenario) + useEffect(() => { + const handleCameraRestarted = (event: CustomEvent<{ cameraId?: number }>) => { + // Reconnect if event is for this camera or all cameras (cameraId undefined or 0) + const eventCamId = event.detail?.cameraId + if (!eventCamId || eventCamId === 0 || eventCamId === cameraId) { + // Force new connection by using current timestamp + const newTimestamp = Date.now() + lastKnownRestartRef.current = newTimestamp + setStreamKey(newTimestamp) + } + } + + window.addEventListener(CAMERA_RESTARTED_EVENT, handleCameraRestarted as EventListener) + return () => { + window.removeEventListener(CAMERA_RESTARTED_EVENT, handleCameraRestarted as EventListener) + } + }, [cameraId]) + + // Check for restart timestamp changes on mount, when camera changes, + // and periodically. This handles: + // - Cross-navigation: Settings restarts camera while Dashboard is unmounted + // - Multi-tab: Another tab triggers restart + // - Edge cases: React batching delays event handling + useEffect(() => { + const checkForRestart = () => { + const storedTimestamp = getStoredRestartTimestamp(cameraId) + // Also check global timestamp (camera 0) for "restart all" scenarios + const globalTimestamp = cameraId !== 0 ? getStoredRestartTimestamp(0) : 0 + const latestTimestamp = Math.max(storedTimestamp, globalTimestamp) + + if (latestTimestamp > lastKnownRestartRef.current) { + // A restart happened while we were unmounted or in another tab + lastKnownRestartRef.current = latestTimestamp + setStreamKey(latestTimestamp) + } + } + + // Check immediately on mount + checkForRestart() + + // Periodically check for restart changes (every 5 seconds) + // This handles multi-tab scenarios and edge cases + const intervalId = setInterval(checkForRestart, 5000) + + return () => clearInterval(intervalId) + }, [cameraId]) + + // Handle connection errors with auto-retry + useEffect(() => { + if (error && !isConnected) { + handleReconnect() + } + }, [error, isConnected, handleReconnect]) + + if (error && !imageUrl) { + return ( +
+
+
+ + + +

{error}

+
+
+
+ ) + } + + if (!imageUrl) { + return ( +
+
+ {/* Loading spinner in top-right corner */} +
+ + + + +
+
+
+ ) + } + + return ( +
+
+ {`Camera +
+
+ ) +} diff --git a/frontend/src/components/CameraSwitcher.tsx b/frontend/src/components/CameraSwitcher.tsx new file mode 100644 index 00000000..d4f8e010 --- /dev/null +++ b/frontend/src/components/CameraSwitcher.tsx @@ -0,0 +1,29 @@ +import type { Camera } from '@/api/types' + +interface CameraSwitcherProps { + cameras: Camera[] + selectedId: number | null + onSelect: (id: number) => void +} + +export function CameraSwitcher({ cameras, selectedId, onSelect }: CameraSwitcherProps) { + // No switcher needed for single camera + if (cameras.length <= 1) return null + + return ( + + ) +} diff --git a/frontend/src/components/ConfigurationPresets.tsx b/frontend/src/components/ConfigurationPresets.tsx new file mode 100644 index 00000000..5377bce6 --- /dev/null +++ b/frontend/src/components/ConfigurationPresets.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useProfiles, useApplyProfile } from '../hooks/useProfiles'; +import { ProfileSaveDialog } from './ProfileSaveDialog'; +import { useToast } from './Toast'; +import { applyRestartRequiredChanges } from '@/api/client'; + +interface ConfigurationPresetsProps { + cameraId: number; + readOnly?: boolean; // Hide save button when true (for Dashboard bottom sheet) +} + +/** + * Configuration Presets Component + * + * Allows users to: + * - Select from saved configuration profiles + * - Apply a profile to quickly change camera settings + * - Save current settings as a new profile + * - Manage existing profiles (delete, set as default) + */ +export function ConfigurationPresets({ cameraId, readOnly = false }: ConfigurationPresetsProps) { + const queryClient = useQueryClient(); + const { data: profiles, isLoading, error } = useProfiles(cameraId); + const { mutate: applyProfile, isPending: isApplying } = useApplyProfile(); + const { addToast } = useToast(); + + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [showSaveDialog, setShowSaveDialog] = useState(false); + + const handleApply = () => { + if (selectedProfileId) { + const profile = profiles?.find(p => p.profile_id === selectedProfileId); + const profileName = profile?.name || 'profile'; + + applyProfile(selectedProfileId, { + onSuccess: async (requiresRestart) => { + if (requiresRestart.length > 0) { + addToast( + `Profile "${profileName}" applied. Restarting camera...`, + 'info' + ); + try { + await applyRestartRequiredChanges(cameraId); + await new Promise(resolve => setTimeout(resolve, 2000)); + await queryClient.invalidateQueries({ queryKey: ['config'] }); + addToast( + `Profile "${profileName}" applied. Camera restarted.`, + 'success' + ); + } catch (err) { + console.error('Failed to restart camera:', err); + addToast( + `Profile applied but camera restart failed. Please restart manually.`, + 'warning' + ); + } + } else { + addToast( + `Profile "${profileName}" applied successfully`, + 'success' + ); + } + setSelectedProfileId(null); + }, + onError: (error) => { + addToast( + `Failed to apply profile: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error' + ); + }, + }); + } + }; + + if (error) { + return ( +
+
+

Failed to load profiles

+

+ {error instanceof Error ? error.message : 'Unable to connect to profiles API'} +

+
+
+ ); + } + + return ( + <> +
+ +
+ {/* Preset selector dropdown */} +
+ +
+ + + +
+
+ + {/* Save button (hidden in read-only mode) */} + {!readOnly && ( + + )} + + {/* Apply button */} + +
+

+ Quick switch camera settings • {profiles?.length || 0} preset{profiles?.length !== 1 ? 's' : ''} +

+
+ + {/* Save dialog */} + {showSaveDialog && ( + setShowSaveDialog(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..a52ce7d2 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,65 @@ +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo) + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }) + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( +
+
+

+ Something went wrong +

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+
+ + +
+
+
+ ) + ) + } + + return this.props.children + } +} diff --git a/frontend/src/components/FullscreenButton.tsx b/frontend/src/components/FullscreenButton.tsx new file mode 100644 index 00000000..d05b6376 --- /dev/null +++ b/frontend/src/components/FullscreenButton.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react' + +interface FullscreenButtonProps { + cameraId: number +} + +export function FullscreenButton({ cameraId }: FullscreenButtonProps) { + const [isFullscreen, setIsFullscreen] = useState(false) + + const toggleFullscreen = (e: React.MouseEvent) => { + e.stopPropagation() + // Find the camera stream container + const container = document.querySelector(`[data-camera-id="${cameraId}"]`) + if (!container) return + + if (!isFullscreen) { + if (container.requestFullscreen) { + container.requestFullscreen() + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen() + } + } + } + + // Listen for fullscreen changes + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + } + }, []) + + return ( + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 00000000..10b195c6 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,145 @@ +import { useState, memo } from 'react' +import { Outlet, Link } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { useAuthContext } from '@/contexts/AuthContext' +import { logout } from '@/api/auth' +import { SystemStatus, VersionDisplay } from '@/components/SystemStatus' + +export const Layout = memo(function Layout() { + const queryClient = useQueryClient() + const { isAuthenticated, role, authRequired } = useAuthContext() + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + + const handleLogout = async () => { + await logout() + // Invalidate auth queries - this triggers AuthContext and AuthGate to update + queryClient.invalidateQueries({ queryKey: ['auth'] }) + } + + return ( +
+
+
+ + + {/* Mobile menu dropdown */} + {mobileMenuOpen && ( +
+
+ setMobileMenuOpen(false)} + className="px-3 py-2 rounded-lg hover:bg-surface transition-colors" + > + Dashboard + + {role === 'admin' && ( + setMobileMenuOpen(false)} + className="px-3 py-2 rounded-lg hover:bg-surface transition-colors" + > + Settings + + )} + setMobileMenuOpen(false)} + className="px-3 py-2 rounded-lg hover:bg-surface transition-colors" + > + Media + + {authRequired && isAuthenticated && ( + + )} + {!authRequired && ( +
+ No Authentication +
+ )} + + {/* Mobile system stats */} + +
+
+ )} +
+
+ +
+ +
+
+ ) +}) diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx new file mode 100644 index 00000000..c1a85067 --- /dev/null +++ b/frontend/src/components/LoginPage.tsx @@ -0,0 +1,127 @@ +import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react'; +import { login } from '@/api/auth'; + +interface LoginPageProps { + onSuccess: () => void; +} + +export function LoginPage({ onSuccess }: LoginPageProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [rememberMe, setRememberMe] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const usernameRef = useRef(null); + + // Focus username on mount + useEffect(() => { + usernameRef.current?.focus(); + }, []); + + const handleSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + const result = await login(username, password, rememberMe); + + setIsLoading(false); + + if (result.success) { + onSuccess(); + } else { + setError(result.error ?? 'Login failed'); + } + }, [username, password, rememberMe, onSuccess]); + + return ( +
+
+ {/* Logo */} +
+

Motion

+

Video Motion Detection

+
+ + {/* Login Card */} +
+

Sign In

+ + +
+ + setUsername(e.target.value)} + className="w-full px-3 py-2 bg-surface border border-gray-700 rounded-lg + focus:outline-none focus:ring-2 focus:ring-primary" + required + autoComplete="username" + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 bg-surface border border-gray-700 rounded-lg + focus:outline-none focus:ring-2 focus:ring-primary" + required + autoComplete="current-password" + disabled={isLoading} + /> +
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + + +
+ + {/* Security note */} +

+ Use HTTPS in production for secure authentication +

+
+
+ ); +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 00000000..0c7e66f4 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,49 @@ +interface PaginationProps { + offset: number; + limit: number; + total: number; + onPageChange: (newOffset: number) => void; + context?: string; // Optional context like "on Jan 15, 2025" +} + +export function Pagination({ offset, limit, total, onPageChange, context }: PaginationProps) { + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(total / limit); + const displayStart = total === 0 ? 0 : offset + 1; + const displayEnd = Math.min(offset + limit, total); + + const canGoPrevious = offset > 0; + const canGoNext = offset + limit < total; + + return ( +
+ + Displaying {displayStart}-{displayEnd} of {total} + {context && {context}} + + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/ProfileSaveDialog.tsx b/frontend/src/components/ProfileSaveDialog.tsx new file mode 100644 index 00000000..4e11fe3b --- /dev/null +++ b/frontend/src/components/ProfileSaveDialog.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { useCreateProfile } from '../hooks/useProfiles'; + +interface ProfileSaveDialogProps { + cameraId: number; + onClose: () => void; +} + +/** + * Dialog for saving current camera settings as a new profile + */ +export function ProfileSaveDialog({ cameraId, onClose }: ProfileSaveDialogProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const { mutate: createProfile, isPending } = useCreateProfile(); + + const handleSave = () => { + if (!name.trim()) { + return; + } + + createProfile( + { + name: name.trim(), + description: description.trim() || undefined, + camera_id: cameraId, + snapshot_current: true, // Capture current settings + }, + { + onSuccess: () => { + onClose(); + }, + onError: (error) => { + console.error('Failed to save profile:', error); + // TODO: Show error notification + }, + } + ); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Save Configuration Preset

+ +
+ + {/* Form */} +
+ {/* Name field */} +
+ + setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., Daytime, Nighttime" + className="w-full px-3 py-2 bg-surface-elevated border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus + maxLength={50} + /> +
+ + {/* Description field */} +
+ + \n" - "
\n\n" - "
\n\n"; - -} - -/* Create the javascript function send_config */ -void cls_webu_html::script_nav() -{ - webua->resp_page += - " function nav_open() {\n" - " document.getElementById('divnav_main').style.width = '10rem';\n" - " document.getElementById('divmain').style.marginLeft = '10rem';\n" - " document.getElementById('menu_btn').style.display= 'none';\n" - " }\n\n" - - " function nav_close() {\n" - " document.getElementById('divnav_main').style.width = '0rem';\n" - " document.getElementById('divmain').style.marginLeft = '0rem';\n" - " document.getElementById('menu_btn').style.display= 'inline';\n" - " }\n\n"; -} - -/* Create the javascript function send_config */ -void cls_webu_html::script_send_config() -{ - webua->resp_page += - " function send_config(category) {\n" - " var formData = new FormData();\n" - " var request = new XMLHttpRequest();\n" - " var xmlhttp = new XMLHttpRequest();\n" - " var camid = document.getElementsByName('camdrop')[0].value;\n\n" - - " if (camid == 0) {\n" - " var pCfg = pData['configuration']['default'];\n" - " } else {\n" - " var pCfg = pData['configuration']['cam'+camid];\n" - " }\n\n" - - " xmlhttp.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " pData = JSON.parse(this.responseText);\n" - " }\n" - " };\n" - - " request.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " xmlhttp.open('GET', pHostFull+'/0/config.json');\n" - " xmlhttp.send();\n\n" - " }\n" - " };\n" - - " formData.append('command', 'config');\n" - " formData.append('camid', camid);\n\n" - " for (jkey in pCfg) {\n" - " if (document.getElementsByName(jkey)[0] != null) {\n" - " if (pCfg[jkey].category == category) {\n" - " if (document.getElementsByName(jkey)[0].type == 'checkbox') {\n" - " formData.append(jkey, document.getElementsByName(jkey)[0].checked);\n" - " } else {\n" - " formData.append(jkey, document.getElementsByName(jkey)[0].value);\n" - " }\n" - " }\n" - " }\n" - " }\n" - " request.open('POST', pHostFull);\n" - " request.send(formData);\n\n" - " }\n\n"; -} - -/* Create the send_action javascript function */ -void cls_webu_html::script_send_action() -{ - webua->resp_page += - " function send_action(actval) {\n\n" - - " var dsp_cam = document.getElementById('div_cam').style.display;\n" - " if ((dsp_cam == 'none' || dsp_cam == '') && (actval != 'config_write')) {\n" - " return;\n" - " }\n\n" - - " var formData = new FormData();\n" - " var camid;\n" - " var ans;\n\n" - - " camid = assign_camid();\n\n" - - " if (actval == 'action_user') {\n" - " ans = prompt('Enter user parameter');\n" - " } else {\n" - " ans = '';\n" - " }\n\n" - - " formData.append('command', actval);\n" - " formData.append('camid', camid);\n" - " formData.append('user', ans);\n\n" - " var request = new XMLHttpRequest();\n" - " request.open('POST', pHostFull);\n" - " request.send(formData);\n\n" - " return;\n" - " }\n\n"; -} - -/* Create the send_reload javascript function */ -void cls_webu_html::script_send_reload() -{ - webua->resp_page += - " function send_reload(actval) {\n\n" - " var formData = new FormData();\n" - " var request = new XMLHttpRequest();\n" - " var xmlhttp = new XMLHttpRequest();\n" - " var camid;\n" - " var ans;\n\n" - - " camid = assign_camid();\n\n" - - " if (actval == 'camera_delete') {\n" - " ans = confirm('Delete camera ' + camid);\n" - " if (ans == false) {\n" - " return;\n" - " }\n" - " }\n\n" - - " xmlhttp.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " pData = JSON.parse(this.responseText);\n" - " gIndxCam = -1;\n" - " assign_config_nav();\n" - " assign_vals(0);\n" - " assign_cams();\n" - " }\n" - " };\n" - - " request.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " xmlhttp.open('GET', pHostFull+'/0/config.json');\n" - " xmlhttp.send();\n\n" - " }\n" - " };\n" - - " formData.append('command', actval);\n" - " formData.append('camid', camid);\n\n" - - " request.open('POST', pHostFull);\n" - " request.send(formData);\n\n" - - " }\n\n"; -} - -/* Create the javascript function dropchange_cam */ -void cls_webu_html::script_dropchange_cam() -{ - webua->resp_page += - " function dropchange_cam(camobj) {\n" - " var indx;\n\n" - - " assign_vals(camobj.value);\n\n" - - " var sect = document.getElementsByName('camdrop');\n" - " for (indx = 0; indx < sect.length; indx++) {\n" - " sect.item(indx).selectedIndex =camobj.selectedIndex;\n" - " }\n\n" - - " gIndxCam = -1;\n" - " for (indx = 0; indx < pData['cameras']['count']; indx++) {\n" - " if (pData['cameras'][indx]['id'] == camobj.value) {\n" - " gIndxCam = indx;\n" - " }\n" - " }\n\n" - - " if (gIndxCam == -1) {\n" - " document.getElementById('cfgpic').src =\n" - " pHostFull+\"/0/mjpg/stream\";\n" - " } else {\n" - " document.getElementById('cfgpic').src =\n" - " pData['cameras'][gIndxCam]['url'] + \"mjpg/stream\" ;\n" - " }\n\n" - - " }\n\n"; -} - -/* Create the javascript function config_hideall */ -void cls_webu_html::script_config_hideall() -{ - webua->resp_page += - " function config_hideall() {\n" - " var sect = document.getElementsByClassName('cls_config');\n" - " for (var i = 0; i < sect.length; i++) {\n" - " sect.item(i).style.display='none';\n" - " }\n" - " return;\n" - " }\n\n"; -} - -/* Create the javascript function config_click */ -void cls_webu_html::script_config_click() -{ - webua->resp_page += - " function config_click(actval) {\n" - " config_hideall();\n" - " document.getElementById('div_cam').style.display='none';\n" - " document.getElementById('div_movies').style.display='none';\n" - " document.getElementById('div_config').style.display='inline';\n" - " document.getElementById('div_' + actval).style.display='inline';\n" - " cams_reset();\n" - " }\n\n"; -} - -/* Create the javascript function assign_camid */ -void cls_webu_html::script_assign_camid() -{ - webua->resp_page += - " function assign_camid() {\n" - " if (gIndxCam == -1 ) {\n" - " camid = 0;\n" - " } else {\n" - " camid = pData['cameras'][gIndxCam]['id'];\n" - " }\n\n" - " return camid; \n" - " }\n\n"; -} - -/* Create the javascript function assign_version */ -void cls_webu_html::script_assign_version() -{ - webua->resp_page += - " function assign_version() {\n" - " var verstr ='Motion \\n'+pData['version'] +'';\n" - " document.getElementById('divnav_version').innerHTML = verstr;\n" - " }\n\n"; -} - -/* Create the javascript function assign_cams */ -void cls_webu_html::script_assign_cams() -{ - webua->resp_page += - " function assign_cams() {\n" - " var camcnt = pData['cameras']['count'];\n" - " var indx = 0;\n" - " var html_drop = \"\\n\";\n" - " var html_nav = \"\\n\";\n" - " var html_mov = \"\\n\";\n\n" - " html_drop += \" \\n\";\n\n" - " var sect = document.getElementsByClassName(\"cls_camdrop\");\n" - " for (indx = 0; indx < sect.length; indx++) {\n" - " sect.item(indx).innerHTML = html_drop;\n" - " }\n\n" - " document.getElementById(\"divnav_cam\").innerHTML = html_nav;\n\n" - " document.getElementById(\"divnav_movies\").innerHTML = html_mov;\n\n" - " return;\n" - " }\n\n"; -} - -/* Create the javascript function assign_actions */ -void cls_webu_html::script_assign_actions() -{ - int indx; - ctx_params_item *itm; - - webua->resp_page += - " function assign_actions() {\n" - " var html_actions = \"\\n\";\n" - " html_actions += \" \";\n"; - - for (indx=0;indxwb_actions->params_cnt;indx++) { - itm = &webu->wb_actions->params_array[indx]; - if ((itm->param_name == "snapshot") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Snapshot\\n\";\n\n" - ; - } else if ((itm->param_name == "event") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Start Event\\n\";\n\n" - - " html_actions += \"\";\n" - " html_actions += \"End Event\\n\";\n\n" - ; - } else if ((itm->param_name == "pause") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Pause On\\n\";\n\n" - - " html_actions += \"\";\n" - " html_actions += \"Pause Off\\n\";\n\n" - - " html_actions += \"\";\n" - " html_actions += \"Pause Schedule\\n\";\n\n" - ; - } else if ((itm->param_name == "camera_add") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Add Camera\\n\";\n\n" - ; - } else if ((itm->param_name == "camera_delete") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Delete Camera\\n\";\n\n" - ; - } else if ((itm->param_name == "config_write") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Save Config\\n\";\n\n" - ; - } else if ((itm->param_name == "stop") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Stop\\n\";\n\n" - ; - } else if ((itm->param_name == "restart") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Start/Restart\\n\";\n\n" - ; - } else if ((itm->param_name == "action_user") && - (itm->param_value == "on")) { - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"User Action\\n\";\n\n" - ; - } - } - - webua->resp_page += - " html_actions += \"\";\n" - " html_actions += \"Show/hide log\\n\";\n\n"; - - webua->resp_page += - " document.getElementById(\"divnav_actions\").innerHTML = html_actions;\n\n" - " return;\n" - - " }\n\n"; -} - -/* Create the javascript function assign_vals */ -void cls_webu_html::script_assign_vals() -{ - webua->resp_page += - " function assign_vals(camid) {\n" - " var pCfg;\n\n" - - " if (camid == 0) {\n" - " pCfg = pData[\"configuration\"][\"default\"];\n" - " } else {\n" - " pCfg = pData[\"configuration\"][\"cam\"+camid];\n" - " }\n\n" - - " for (jkey in pCfg) {\n" - " if (document.getElementsByName(jkey)[0] != null) {\n" - " if (pCfg[jkey].enabled) {\n" - " document.getElementsByName(jkey)[0].disabled = false;\n" - " if (document.getElementsByName(jkey)[0].type == \"checkbox\") {\n" - " document.getElementsByName(jkey)[0].checked = pCfg[jkey].value;\n" - " } else {\n" - " document.getElementsByName(jkey)[0].value = pCfg[jkey].value;\n" - " }\n" - " } else {\n" - " document.getElementsByName(jkey)[0].disabled = true;\n" - " document.getElementsByName(jkey)[0].value = '';\n" - " }\n" - " } else {\n" - " console.log('Uncoded ' + jkey + ' : ' + pCfg[jkey].value);\n" - " }\n" - " }\n" - " }\n\n"; -} - -/* Create the javascript function assign_config_nav */ -void cls_webu_html::script_assign_config_nav() -{ - webua->resp_page += - " function assign_config_nav() {\n" - " var pCfg = pData['configuration']['default'];\n" - " var pCat = pData['categories'];\n" - " var html_nav = \"\\n\";\n\n" - - " for (jcat in pCat) {\n" - " html_nav += \"\";\n" - " html_nav += pCat[jcat][\"display\"]+\"\\n\";\n\n" - " }\n\n" - - " document.getElementById(\"divnav_config\").innerHTML = html_nav;\n\n" - - " }\n\n"; -} - -/* Create the javascript function assign_config_item */ -void cls_webu_html::script_assign_config_item() -{ - webua->resp_page += - " function assign_config_item(jkey) {\n" - " var pCfg = pData['configuration']['default'];\n" - " var html_cfg = \"\";\n" - " var indx_lst = 0;\n\n" - - " html_cfg += \"\\n\";\n\n" - " if (pCfg[jkey][\"type\"] == \"string\") {\n" - " html_cfg += \"\";\n\n" - " } else if (pCfg[jkey][\"type\"] == \"bool\") {\n" - " html_cfg += \"\";\n\n" - " } else if (pCfg[jkey][\"type\"] == \"int\") {\n" - " html_cfg += \"\";\n\n" - " } else if (pCfg[jkey][\"type\"] == \"list\") {\n" - " html_cfg += \"\";\n" - " }\n" - " html_cfg += \"\\n\";\n\n" - - " return html_cfg;\n\n" - - " }\n\n"; -} - -/* Create the javascript function assign_config_cat */ -void cls_webu_html::script_assign_config_cat() -{ - webua->resp_page += - " function assign_config_cat(jcat) {\n" - " var pCfg = pData['configuration']['default'];\n" - " var pCat = pData['categories'];\n" - " var html_cfg = \"\";\n\n" - - " html_cfg += \"\\n\";\n\n" - - " return html_cfg;\n\n" - - " }\n\n"; -} - -/* Create the javascript function assign_config */ -void cls_webu_html::script_assign_config() -{ - webua->resp_page += - " function assign_config() {\n" - " var pCat = pData['categories'];\n" - " var html_cfg = \"\";\n\n" - - " assign_config_nav();\n\n" - - " for (jcat in pCat) {\n" - " html_cfg += assign_config_cat(jcat);\n" - " }\n\n" - - " html_cfg += \"


\";\n" - " html_cfg += \"\\n\";\n" - " html_cfg += \"
\\n;\"\n\n" - - - " document.getElementById(\"div_config\").innerHTML = html_cfg;\n\n" - - " }\n\n"; -} - -/* Create the javascript function init_form */ -void cls_webu_html::script_initform() -{ - webua->resp_page += - " function initform() {\n" - " var xmlhttp = new XMLHttpRequest();\n\n" - - " pHostFull = '//' + window.location.hostname;\n" - " pHostFull = pHostFull + ':' + window.location.port;\n\n" - - " xmlhttp.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " pData = JSON.parse(this.responseText);\n" - " gIndxCam = -1;\n" - " gGetImgs = 1;\n" - " gIndxScan = -1;\n" - " gLogNbr = 0;\n\n" - - " assign_config();\n" - " assign_version();\n" - " assign_vals(0);\n" - " assign_cams();\n" - " assign_actions();\n" - " cams_all_click();\n" - " nav_close();\n" - - " }\n" - " };\n" - " xmlhttp.open('GET', pHostFull+'/0/config.json');\n" - " xmlhttp.send();\n" - " }\n\n"; -} - -/* Create the javascript function display_cameras */ -void cls_webu_html::script_display_cameras() -{ - webua->resp_page += - " function display_cameras() {\n" - " document.getElementById('divnav_config').style.display = 'none';\n" - " document.getElementById('divnav_actions').style.display = 'none';\n" - " document.getElementById('divnav_movies').style.display = 'none';\n" - " if (document.getElementById('divnav_cam').style.display == 'block'){\n" - " document.getElementById('divnav_cam').style.display = 'none';\n" - " } else {\n" - " document.getElementById('divnav_cam').style.display = 'block';\n" - " }\n" - " }\n\n"; -} - -/* Create the javascript function display_config */ -void cls_webu_html::script_display_config() -{ - webua->resp_page += - " function display_config() {\n" - " document.getElementById('divnav_cam').style.display = 'none';\n" - " document.getElementById('divnav_actions').style.display = 'none';\n" - " document.getElementById('divnav_movies').style.display = 'none';\n" - " if (document.getElementById('divnav_config').style.display == 'block') {\n" - " document.getElementById('divnav_config').style.display = 'none';\n" - " } else {\n" - " document.getElementById('divnav_config').style.display = 'block';\n" - " }\n" - " gIndxScan = -1; \n" - " cams_timer_stop();\n" - " }\n\n"; -} - -/* Create the javascript function display_movies */ -void cls_webu_html::script_display_movies() -{ - webua->resp_page += - " function display_movies() {\n" - " document.getElementById('divnav_cam').style.display = 'none';\n" - " document.getElementById('divnav_actions').style.display = 'none';\n" - " document.getElementById('divnav_config').style.display = 'none';\n" - " if (document.getElementById('divnav_movies').style.display == 'block') {\n" - " document.getElementById('divnav_movies').style.display = 'none';\n" - " } else {\n" - " document.getElementById('divnav_movies').style.display = 'block';\n" - " }\n" - " gIndxScan = -1; \n" - " cams_timer_stop();\n" - " }\n\n"; -} - -/* Create the javascript function display_actions */ -void cls_webu_html::script_display_actions() -{ - webua->resp_page += - " function display_actions() {\n" - " document.getElementById('divnav_cam').style.display = 'none';\n" - " document.getElementById('divnav_config').style.display = 'none';\n" - " if (document.getElementById('divnav_actions').style.display == 'block') {\n" - " document.getElementById('divnav_actions').style.display = 'none';\n" - " } else {\n" - " document.getElementById('divnav_actions').style.display = 'block';\n" - " }\n" - " gIndxScan = -1; \n" - " }\n\n"; -} - -/* Create the camera_buttons_ptz javascript function */ -void cls_webu_html::script_camera_buttons_ptz() -{ - webua->resp_page += - " function camera_buttons_ptz() {\n\n" - " var html_preview = \"\";\n" - - " html_preview += \"\";\n" - " html_preview += \"\\n\";\n" - - " html_preview += \" \\n\";\n" - - " html_preview += \" \\n\";\n" - - " html_preview += \" \\n\";\n" - - " html_preview += \" \\n\";\n" - - " html_preview += \" \\n\";\n" - - " html_preview += \" \\n\";\n" - " html_preview += \"\\n\";\n" - " html_preview += \"
    
    
    

\";\n" - - " return html_preview;\n\n" - - " }\n\n"; -} - -void cls_webu_html::script_image_picall() -{ - webua->resp_page += - " function image_picall() {\n\n" - " document.getElementById('picall').addEventListener('click',function(event){\n" - " bounds=this.getBoundingClientRect();\n" - " var locx,locy,locw, loch,pctx,pcty;\n" - " var indx, camcnt, caminfo;\n" - " locx = Math.floor(event.pageX - bounds.left - window.scrollX);\n" - " locy = Math.floor(event.pageY - bounds.top - window.scrollY);\n" - " locw = Math.floor(bounds.width);\n" - " loch = Math.floor(bounds.height);\n" - " pctx = ((locx*100)/locw);\n" - " pcty = ((locy*100)/loch);\n" - " camcnt = pData['cameras']['count'];\n" - " for (indx=0; indx= pData['cameras'][indx]['all_xpct_st']) &&\n" - " (pctx <= pData['cameras'][indx]['all_xpct_en']) &&\n" - " (pcty >= pData['cameras'][indx]['all_ypct_st']) &&\n" - " (pcty <= pData['cameras'][indx]['all_ypct_en'])) {\n" - " cams_one_click(indx);\n" - " }\n" - " }\n" - " });\n" - " }\n\n"; -} - -/* Create the image_pantilt javascript function */ -void cls_webu_html::script_image_pantilt() -{ - webua->resp_page += - " function image_pantilt() {\n\n" - " if (gIndxCam == -1 ) {\n" - " return;\n" - " }\n\n" - " document.getElementById('pic'+ gIndxCam).addEventListener('click',function(event){\n" - " bounds=this.getBoundingClientRect();\n" - " var x = Math.floor(event.pageX - bounds.left - window.scrollX);\n" - " var y = Math.floor(event.pageY - bounds.top - window.scrollY);\n" - " var w = Math.floor(bounds.width);\n" - " var h = Math.floor(bounds.height);\n" - " var qtr_x = Math.floor(bounds.width/4);\n" - " var qtr_y = Math.floor(bounds.height/4);\n" - " if ((x > qtr_x) && (x < (w - qtr_x)) && (y < qtr_y)) {\n" - " send_action('tilt_up');\n" - " } else if ((x > qtr_x) && (x < (w - qtr_x)) && (y >(h - qtr_y))) {\n" - " send_action('tilt_down');\n" - " } else if ((x < qtr_x) && (y > qtr_y) && (y < (h - qtr_y))) {\n" - " send_action('pan_left');\n" - " } else if ((x >(w - qtr_x)) && (y > qtr_y) && (y < (h - qtr_y))) {\n" - " send_action('pan_right');\n" - " }\n" - " });\n" - " }\n\n"; -} - -/* Create the cams_reset javascript function */ -void cls_webu_html::script_cams_reset() -{ - webua->resp_page += - " function cams_timer_stop() {\n" - " clearInterval(cams_one_timer);\n" - " clearInterval(cams_all_timer);\n" - " clearInterval(cams_scan_timer);\n" - " }\n\n"; - - webua->resp_page += - " function cams_reset() {\n" - " var indx, camcnt;\n" - " camcnt = pData['cameras']['count'];\n" - " for (indx=0; indxresp_page += - " function cams_one_click(index_cam) {\n\n" - " var html_preview = \"\";\n" - " var camid;\n\n" - " config_hideall();\n" - " cams_timer_stop();\n" - " gIndxCam = index_cam;\n\n" - " gIndxScan = -1; \n\n" - - " if (gIndxCam == -1 ) {\n" - " return;\n" - " }\n\n" - - " camid = pData['cameras'][index_cam].id;\n" - - " if ((pData['configuration']['cam'+camid].stream_preview_ptz.value == true)) {\n" - " html_preview += camera_buttons_ptz();\n" - " }\n\n" - " if (pData['configuration']['cam'+camid].stream_preview_method.value == 'static') {\n" - " html_preview += \"\\n\";\n" - " } else { \n" - " html_preview += \"\\n\";\n" - " }\n" - " document.getElementById('div_config').style.display='none';\n" - " document.getElementById('div_movies').style.display = 'none';\n" - " cams_reset();\n" - " document.getElementById('div_cam').style.display='block';\n" - " document.getElementById('div_cam').innerHTML = html_preview;\n\n" - " image_pantilt();\n\n" - " cams_one_timer = setInterval(cams_one_fnc, 1000);\n\n" - " }\n\n"; -} - -/* Create the cams_all_click javascript function */ -void cls_webu_html::script_cams_all_click() -{ - webua->resp_page += - " function cams_all_click() {\n\n" - " var html_preview = \"\";\n" - " var indx, chk;\n" - " var camid;\n\n" - - " config_hideall();\n" - " cams_timer_stop();\n" - " gIndxCam = -1;\n" - " gIndxScan = -1; \n\n" - " var camcnt = pData['cameras']['count'];\n" - " html_preview += \"\";\n" - " chk = 0;\n" - " for (indx=0; indx\\n\";\n" - " if (pData['configuration']['cam'+camid].stream_preview_newline.value == true) {\n" - " html_preview += \"
\\n\";\n" - " }\n" - " } else { \n" - " html_preview += \"\\n\";\n" - " if (pData['configuration']['cam'+camid].stream_preview_newline.value == true) {\n" - " html_preview += \"
\\n\";\n" - " }\n" - " } \n" - " }\n" - " } else { \n" - " html_preview += \"\\n\";\n" - " }\n" - " document.getElementById('div_config').style.display='none';\n" - " document.getElementById('div_movies').style.display = 'none';\n" - " cams_reset();\n" - " document.getElementById('div_cam').style.display='block';\n" - " document.getElementById('div_cam').innerHTML = html_preview;\n" - " if (chk == 0) {\n" - " cams_all_timer = setInterval(cams_all_fnc, 1000);\n" - " } else {\n" - " image_picall();\n" - " }\n\n" - " }\n\n"; -} - -/* Create the movies_page javascript function */ -void cls_webu_html::script_movies_page() -{ - webua->resp_page += - " function movies_page() {\n\n" - " var html_tab = \"
\";\n" - " var indx, movcnt, camid, uri;\n" - " var fname,fsize,fdate;\n\n" - - " if (gIndxCam == -1 ) {\n" - " return;\n" - " }\n\n" - - " camid = assign_camid();\n" - " uri = pHostFull+'/'+camid+'/movies/';\n\n" - - " movcnt = pMovies['movies'][gIndxCam].count;\n" - " html_tab +=\"\";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n\n" - - " for (indx = 0; indx < movcnt; indx++) {\n" - " fname = pMovies['movies'][gIndxCam][indx]['name'];\n" - " fsize = pMovies['movies'][gIndxCam][indx]['size'];\n" - " fdate = pMovies['movies'][gIndxCam][indx]['date'];\n\n" - " ftime = pMovies['movies'][gIndxCam][indx]['time'];\n\n" - " fdavg = pMovies['movies'][gIndxCam][indx]['diff_avg'];\n\n" - " fsmin = pMovies['movies'][gIndxCam][indx]['sdev_min'];\n\n" - " fsmax = pMovies['movies'][gIndxCam][indx]['sdev_max'];\n\n" - " fsavg = pMovies['movies'][gIndxCam][indx]['sdev_avg'];\n\n" - - " html_tab +=\"\";\n" - " html_tab +=\" \";\n" - - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - " html_tab +=\" \";\n" - - " html_tab +=\" \";\n" - " html_tab +=\"\";\n" - " }\n" - " html_tab +=\"
NameSizeDatetimediff_avgsdev_minsdev_maxsdev_avg
\" + fname + \"\"+fsize+\"\"+fdate+\"\"+ftime+\"\"+fdavg+\"\"+fsmin+\"\"+fsmax+\"\"+fsavg+\"
\";\n" - " html_tab +=\"
\";\n\n" - - " document.getElementById('div_config').style.display='none';\n" - " document.getElementById('div_cam').style.display='none';\n" - " cams_reset();\n" - " document.getElementById('div_movies').style.display='block';\n" - " document.getElementById('div_movies').innerHTML = html_tab;\n\n" - " }\n\n"; -} - -/* Create the movies_page javascript function */ -void cls_webu_html::script_movies_click() -{ - webua->resp_page += - " function movies_click(index_cam) {\n" - " var camid, indx, camcnt, uri;\n\n" - - " gIndxCam = index_cam;\n" - " gIndxScan = -1; \n" - " camid = assign_camid();\n" - " uri = pHostFull+'/'+camid+'/movies.json';\n\n" - " config_hideall();\n" - " cams_reset();\n" - " var xmlhttp = new XMLHttpRequest();\n" - " xmlhttp.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " pMovies = JSON.parse(this.responseText);\n" - " movies_page();\n" - " }\n" - " };\n" - " xmlhttp.open('GET', uri);\n" - " xmlhttp.send();\n" - " }\n\n"; -} - -/* Create the cams_scan_click javascript function */ -void cls_webu_html::script_cams_scan_click() -{ - webua->resp_page += - " function cams_scan_click() {\n\n" - " cams_timer_stop();\n\n" - " gIndxCam = -1; \n" - " gIndxScan = 0; \n\n" - " cams_scan_timer = setInterval(cams_scan_fnc, 5);\n" - " }\n\n"; -} - -/* Create the cams_one_fnc javascript function */ -void cls_webu_html::script_cams_one_fnc() -{ - webua->resp_page += - " function cams_one_fnc () {\n" - " var img = new Image();\n" - " var camid;\n\n" - " if (gIndxCam == -1 ) {\n" - " return;\n" - " }\n\n" - " camid = pData['cameras'][gIndxCam]['id'];\n\n" - " if (pData['configuration']['cam'+camid].stream_preview_method.value == 'static') {\n" - " pic_url[0] = pData['cameras'][gIndxCam]['url'] + \"static/stream/t\" + new Date().getTime();\n" - " img.src = pic_url[0];\n" - " document.getElementById('pic'+gIndxCam).src = pic_url[0];\n" - " }\n" - " }\n\n "; -} - -/* Create the cams_all_fnc javascript function */ -void cls_webu_html::script_cams_all_fnc() -{ - webua->resp_page += - " function cams_all_fnc () {\n" - " var previndx = gGetImgs;\n" - " gGetImgs++;\n" - " if (gGetImgs >= pData['cameras']['count']) {\n" - " gGetImgs = 0;\n" - " }\n" - " camid = pData['cameras'][gGetImgs]['id'];\n" - " if (pData['configuration']['cam'+camid].stream_preview_method.value == 'static') {\n" - " document.getElementById('pic'+previndx).src =\n" - " pData['cameras'][previndx]['url'] + \"static/stream/t\" + new Date().getTime();\n" - " document.getElementById('pic'+gGetImgs).src =\n" - " pData['cameras'][gGetImgs]['url'] + \"mjpg/stream\";\n" - " }\n" - " }\n\n"; -} - -/* Create the scancam_function javascript function */ -void cls_webu_html::script_cams_scan_fnc() -{ - webua->resp_page += - " function cams_scan_fnc() {\n" - " var html_preview = \"\";\n" - " var camid;\n" - " var camcnt = pData['cameras']['count'];\n\n" - " cams_reset();\n" - - " if(gIndxScan == -1) {\n" - " clearInterval(cams_scan_timer);\n" - " return;\n" - " }\n\n" - - " if(gIndxScan == (camcnt-1)) {\n" - " gIndxScan = 0;\n" - " } else { \n" - " gIndxScan++;\n" - " }\n\n" - - " camid = pData['cameras'][gIndxScan]['id'];\n" - " clearInterval(cams_scan_timer);\n" - - " cams_scan_timer = setInterval(cams_scan_fnc,\n" - " pData['configuration']['cam'+camid].stream_scan_time.value * 1000 \n" - " );\n" - - " html_preview += \"\\n\";\n" - " document.getElementById('div_config').style.display='none';\n" - " document.getElementById('div_movies').style.display='none';\n" - " cams_reset();\n" - " document.getElementById('div_cam').style.display='block';\n" - " document.getElementById('div_cam').innerHTML = html_preview;\n" - " };\n\n"; -} - -void cls_webu_html::script_log_display() -{ - webua->resp_page += - " function log_display() {\n" - " var itm, msg, nbr, indx, txtalog;\n" - " txtalog = document.getElementById('txta_log').value;\n" - " for (indx = 0; indx < 1000; indx++) {\n" - " itm = pLog[indx];\n" - " if (typeof(itm) != 'undefined') {\n" - " msg = pLog[indx]['logmsg'];\n" - " if (typeof(msg) != 'undefined') {\n" - " gLogNbr = pLog[indx]['lognbr'];\n" - " if (txtalog.length > 2000) {\n" - " txtalog = txtalog.substring(txtalog.length - 2000);\n" - " txtalog = txtalog.substring(txtalog.search('\\n'));\n" - " }\n" - " txtalog += '\\n' + msg;\n" - " }\n" - " }\n" - " }\n" - " document.getElementById('txta_log').enabled = true;\n" - " document.getElementById('txta_log').value = txtalog;\n" - " document.getElementById('txta_log').scrollTop =\n" - " document.getElementById('txta_log').scrollHeight;\n" - " document.getElementById('txta_log').enabled = false;\n" - " }\n\n"; - -} - -void cls_webu_html::script_log_get() -{ - webua->resp_page += - " function log_get() {\n" - " var xmlhttp = new XMLHttpRequest();\n" - " xmlhttp.onreadystatechange = function() {\n" - " if (this.readyState == 4 && this.status == 200) {\n" - " pLog = JSON.parse(this.responseText);\n" - " log_display();\n" - " }\n" - " };\n" - " xmlhttp.open('GET', pHostFull+'/0/log/'+ gLogNbr);\n" - " xmlhttp.send();\n" - " }\n\n"; - -} - -void cls_webu_html::script_log_showhide() -{ - webua->resp_page += - " function log_showhide() {\n" - " if (document.getElementById('div_log').style.display == 'none') {\n" - " document.getElementById('div_log').style.display='block';\n" - " document.getElementById('txta_log').value = '';\n" - " log_timer = setInterval(log_get, 2000);\n" - " } else {\n" - " document.getElementById('div_log').style.display='none';\n" - " document.getElementById('txta_log').value = '';\n" - " clearInterval(log_timer);\n" - " }\n" - " }\n\n"; - -} - -/* Call all the functions to create the java scripts of page*/ -void cls_webu_html::script() -{ - webua->resp_page += " \n\n"; -} - -/* Create the body section of the web page */ -void cls_webu_html::body() -{ - webua->resp_page += "\n"; - - navbar(); - - divmain(); - - script(); - - webua->resp_page += "\n"; -} - -void cls_webu_html::default_page() -{ - webua->resp_page += "\n" - "\n"; - head(); - body(); - webua->resp_page += "\n"; -} - -void cls_webu_html::user_page() -{ - char response[PATH_MAX]; - FILE *fp = NULL; - - webua->resp_page = ""; - fp = myfopen(app->cfg->webcontrol_html.c_str(), "re"); - if (fp == NULL) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO - , _("Invalid user html file: %s") - , app->cfg->webcontrol_html.c_str()); - } else { - while (fgets(response, PATH_MAX-1, fp)) { - webua->resp_page += response; - } - myfclose(fp); - } -} - -void cls_webu_html::main() -{ - pthread_mutex_lock(&app->mutex_post); - if (app->cfg->webcontrol_interface == "user") { - user_page(); - } else { - default_page(); - } - pthread_mutex_unlock(&app->mutex_post); - - if (webua->resp_page == "") { - webua->bad_request(); - } else { - webua->mhd_send(); - } -} - -cls_webu_html::cls_webu_html(cls_webu_ans *p_webua) -{ - app = p_webua->app; - webu = p_webua->webu; - webua = p_webua; -} - -cls_webu_html::~cls_webu_html() -{ - app = nullptr; - webu = nullptr; - webua = nullptr; -} diff --git a/src/webu_html.hpp b/src/webu_html.hpp deleted file mode 100644 index 0d19c218..00000000 --- a/src/webu_html.hpp +++ /dev/null @@ -1,80 +0,0 @@ -/* - * This file is part of Motion. - * - * Motion is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Motion is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Motion. If not, see . - * -*/ - -#ifndef _INCLUDE_WEBU_HTML_HPP_ -#define _INCLUDE_WEBU_HTML_HPP_ - class cls_webu_html { - public: - cls_webu_html(cls_webu_ans *p_webua); - ~cls_webu_html(); - void main(); - private: - cls_motapp *app; - cls_webu *webu; - cls_webu_ans *webua; - - void style_navbar(); - void style_config(); - void style_base(); - void style(); - void head(); - void navbar(); - void divmain(); - void script_nav(); - void script_send_config(); - void script_send_action(); - void script_send_reload(); - void script_dropchange_cam(); - void script_config_hideall(); - void script_config_click(); - void script_assign_camid(); - void script_assign_version(); - void script_assign_cams(); - void script_assign_actions(); - void script_assign_vals(); - void script_assign_config_nav(); - void script_assign_config_item(); - void script_assign_config_cat(); - void script_assign_config(); - void script_initform(); - void script_display_cameras(); - void script_display_config(); - void script_display_movies(); - void script_display_actions(); - void script_camera_buttons_ptz(); - void script_image_picall(); - void script_image_pantilt(); - void script_cams_reset(); - void script_cams_one_click(); - void script_cams_all_click(); - void script_movies_page(); - void script_movies_click(); - void script_cams_scan_click(); - void script_cams_one_fnc(); - void script_cams_all_fnc(); - void script_cams_scan_fnc(); - void script_log_display(); - void script_log_get(); - void script_log_showhide(); - void script(); - void body(); - void default_page(); - void user_page(); - }; - -#endif /* _INCLUDE_WEBU_HTML_HPP_ */ diff --git a/src/webu_json.cpp b/src/webu_json.cpp index 3c81164b..ea064fe8 100644 --- a/src/webu_json.cpp +++ b/src/webu_json.cpp @@ -14,17 +14,180 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_json.cpp - JSON REST API Implementation + * + * This module implements the JSON REST API for configuration management, + * camera control, status queries, and profile operations, serving as the + * primary interface between the React frontend and Motion backend. + * + */ #include "motion.hpp" #include "util.hpp" #include "camera.hpp" #include "conf.hpp" +#include "conf_profile.hpp" +#include "cam_detect.hpp" #include "logger.hpp" #include "webu.hpp" #include "webu_ans.hpp" +#include "webu_auth.hpp" #include "webu_json.hpp" #include "dbse.hpp" +#include "libcam.hpp" +#include "netcam.hpp" +#include "video_v4l2.hpp" +#include "json_parse.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* CPU-efficient polygon fill using scanline algorithm + * Fills polygon interior with specified value in bitmap + * O(height * edges) complexity, minimal memory allocation + */ +static void fill_polygon(u_char *bitmap, int width, int height, + const std::vector> &polygon, u_char fill_val) +{ + if (polygon.size() < 3) return; + + /* Find vertical bounds */ + int min_y = height, max_y = 0; + for (const auto &pt : polygon) { + if (pt.second < min_y) min_y = pt.second; + if (pt.second > max_y) max_y = pt.second; + } + + /* Clamp to image bounds */ + if (min_y < 0) min_y = 0; + if (max_y >= height) max_y = height - 1; + + /* Scanline fill */ + std::vector x_intersects; + for (int y = min_y; y <= max_y; y++) { + x_intersects.clear(); + + /* Find intersections with polygon edges */ + size_t n = polygon.size(); + for (size_t i = 0; i < n; i++) { + int x1 = polygon[i].first; + int y1 = polygon[i].second; + int x2 = polygon[(i + 1) % n].first; + int y2 = polygon[(i + 1) % n].second; + + /* Check if edge crosses this scanline */ + if ((y1 <= y && y2 > y) || (y2 <= y && y1 > y)) { + /* Compute x intersection using integer math to avoid float */ + int x = x1 + ((y - y1) * (x2 - x1)) / (y2 - y1); + x_intersects.push_back(x); + } + } + + /* Sort intersections */ + std::sort(x_intersects.begin(), x_intersects.end()); + + /* Fill between pairs */ + for (size_t i = 0; i + 1 < x_intersects.size(); i += 2) { + int xs = x_intersects[i]; + int xe = x_intersects[i + 1]; + + /* Clamp to image bounds */ + if (xs < 0) xs = 0; + if (xe >= width) xe = width - 1; + + /* Fill the span */ + for (int x = xs; x <= xe; x++) { + bitmap[y * width + x] = fill_val; + } + } + } +} + +/* Generate auto-path for mask file in target_dir */ +static std::string build_mask_path(cls_camera *cam, const std::string &type) +{ + std::string target = cam->cfg->target_dir; + if (target.empty()) { + target = "/var/lib/motion"; + } + /* Remove trailing slash */ + if (!target.empty() && target.back() == '/') { + target.pop_back(); + } + return target + "/cam" + std::to_string(cam->cfg->device_id) + + "_" + type + ".pgm"; +} + +/* Hot-reload parameter dispatch table + * Maps parameter names to lambda functions that apply the change to a camera + * This replaces the 28-branch if/else chain with O(1) hash map lookup + */ +namespace { + using HotReloadFunc = std::function; + + const std::unordered_map hot_reload_map = { + {"libcam_brightness", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_brightness(atof(val.c_str())); + }}, + {"libcam_contrast", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_contrast(atof(val.c_str())); + }}, + {"libcam_gain", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_gain(atof(val.c_str())); + }}, + {"libcam_awb_enable", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_awb_enable(val == "true" || val == "1"); + }}, + {"libcam_awb_mode", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_awb_mode(atoi(val.c_str())); + }}, + {"libcam_awb_locked", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_awb_locked(val == "true" || val == "1"); + }}, + {"libcam_colour_temp", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_colour_temp(atoi(val.c_str())); + }}, + {"libcam_colour_gain_r", [](cls_camera *cam, const std::string &val) { + float r = atof(val.c_str()); + float b = cam->cfg->parm_cam.libcam_colour_gain_b; + cam->set_libcam_colour_gains(r, b); + }}, + {"libcam_colour_gain_b", [](cls_camera *cam, const std::string &val) { + float r = cam->cfg->parm_cam.libcam_colour_gain_r; + float b = atof(val.c_str()); + cam->set_libcam_colour_gains(r, b); + }}, + {"libcam_af_mode", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_af_mode(atoi(val.c_str())); + }}, + {"libcam_lens_position", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_lens_position(atof(val.c_str())); + }}, + {"libcam_af_range", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_af_range(atoi(val.c_str())); + }}, + {"libcam_af_speed", [](cls_camera *cam, const std::string &val) { + cam->set_libcam_af_speed(atoi(val.c_str())); + }}, + {"libcam_af_trigger", [](cls_camera *cam, const std::string &val) { + int v = atoi(val.c_str()); + if (v == 0) { + cam->trigger_libcam_af_scan(); + } else { + cam->cancel_libcam_af_scan(); + } + }}, + }; +} std::string cls_webu_json::escstr(std::string invar) { @@ -40,13 +203,73 @@ std::string cls_webu_json::escstr(std::string invar) return outvar; } +void cls_webu_json::parms_item_detail(cls_config *conf, std::string pNm) +{ + ctx_params *params; + ctx_params_item *itm; + int indx; + + params = new ctx_params; + params->params_cnt = 0; + mylower(pNm); + + if (pNm == "v4l2_params") { + util_parms_parse(params, pNm, conf->v4l2_params); + } else if (pNm == "netcam_params") { + util_parms_parse(params, pNm, conf->netcam_params); + } else if (pNm == "netcam_high_params") { + util_parms_parse(params, pNm, conf->netcam_high_params); + } else if (pNm == "libcam_params") { + util_parms_parse(params, pNm, conf->libcam_params); + } else if (pNm == "schedule_params") { + util_parms_parse(params, pNm, conf->schedule_params); + } else if (pNm == "picture_schedule_params") { + util_parms_parse(params, pNm, conf->picture_schedule_params); + } else if (pNm == "cleandir_params") { + util_parms_parse(params, pNm, conf->cleandir_params); + } else if (pNm == "secondary_params") { + util_parms_parse(params, pNm, conf->secondary_params); + } else if (pNm == "webcontrol_actions") { + util_parms_parse(params, pNm, conf->webcontrol_actions); + } else if (pNm == "webcontrol_headers") { + util_parms_parse(params, pNm, conf->webcontrol_headers); + } else if (pNm == "stream_preview_params") { + util_parms_parse(params, pNm, conf->stream_preview_params); + } else if (pNm == "snd_params") { + util_parms_parse(params, pNm, conf->snd_params); + } + + webua->resp_page += ",\"count\":"; + webua->resp_page += std::to_string(params->params_cnt); + + if (params->params_cnt > 0) { + webua->resp_page += ",\"parsed\" :{"; + for (indx=0; indxparams_cnt; indx++) { + itm = ¶ms->params_array[indx]; + if (indx != 0) { + webua->resp_page += ","; + } + webua->resp_page += "\""+std::to_string(indx)+"\":"; + webua->resp_page += "{\"name\":\""+itm->param_name+"\","; + webua->resp_page += "\"value\":\""+itm->param_value+"\"}"; + + } + webua->resp_page += "}"; + } + + mydelete(params); + +} + void cls_webu_json::parms_item(cls_config *conf, int indx_parm) { std::string parm_orig, parm_val, parm_list, parm_enable; + std::string parm_name = config_parms[indx_parm].parm_name; + bool password_set = false; parm_orig = ""; parm_val = ""; - parm_list = ""; + parm_list = "[]"; // Default to empty JSON array for valid JSON if (app->cfg->webcontrol_parms < PARM_LEVEL_LIMITED) { parm_enable = "false"; @@ -57,7 +280,23 @@ void cls_webu_json::parms_item(cls_config *conf, int indx_parm) conf->edit_get(config_parms[indx_parm].parm_name , parm_orig, config_parms[indx_parm].parm_cat); - parm_val = escstr(parm_orig); + /* Mask password values for authentication parameters + * Returns username with empty password, plus password_set flag */ + if (parm_name == "webcontrol_authentication" || + parm_name == "webcontrol_user_authentication") { + size_t colon_pos = parm_orig.find(':'); + if (colon_pos != std::string::npos) { + std::string username = parm_orig.substr(0, colon_pos); + std::string password = parm_orig.substr(colon_pos + 1); + password_set = !password.empty(); + /* Return username with empty password portion */ + parm_val = escstr(username) + ":"; + } else { + parm_val = ""; + } + } else { + parm_val = escstr(parm_orig); + } if (config_parms[indx_parm].parm_type == PARM_TYP_INT) { webua->resp_page += @@ -92,7 +331,6 @@ void cls_webu_json::parms_item(cls_config *conf, int indx_parm) } else if (config_parms[indx_parm].parm_type == PARM_TYP_LIST) { conf->edit_list(config_parms[indx_parm].parm_name , parm_list, config_parms[indx_parm].parm_cat); - webua->resp_page += "\"" + config_parms[indx_parm].parm_name + "\"" + ":{" + @@ -102,16 +340,30 @@ void cls_webu_json::parms_item(cls_config *conf, int indx_parm) ",\"type\":\"" + conf->type_desc(config_parms[indx_parm].parm_type) + "\"" + ",\"list\":" + parm_list + "}"; - - } else { + } else if (config_parms[indx_parm].parm_type == PARM_TYP_PARAMS) { webua->resp_page += "\"" + config_parms[indx_parm].parm_name + "\"" + ":{" + " \"value\":\"" + parm_val + "\"" + ",\"enabled\":" + parm_enable + ",\"category\":" + std::to_string(config_parms[indx_parm].parm_cat) + - ",\"type\":\""+ conf->type_desc(config_parms[indx_parm].parm_type) + "\"" + - "}"; + ",\"type\":\""+ conf->type_desc(config_parms[indx_parm].parm_type) + "\""; + parms_item_detail(conf, config_parms[indx_parm].parm_name); + webua->resp_page += "}"; + } else { + webua->resp_page += + "\"" + parm_name + "\"" + + ":{" + + " \"value\":\"" + parm_val + "\"" + + ",\"enabled\":" + parm_enable + + ",\"category\":" + std::to_string(config_parms[indx_parm].parm_cat) + + ",\"type\":\""+ conf->type_desc(config_parms[indx_parm].parm_type) + "\""; + /* Add password_set flag for authentication parameters */ + if (parm_name == "webcontrol_authentication" || + parm_name == "webcontrol_user_authentication") { + webua->resp_page += ",\"password_set\":" + std::string(password_set ? "true" : "false"); + } + webua->resp_page += "}"; } } @@ -258,7 +510,7 @@ void cls_webu_json::movies_list() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "movies") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Movies via webcontrol disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Movies via webcontrol disabled"); webua->resp_page += "{\"count\" : 0} "; webua->resp_page += ",\"device_id\" : "; webua->resp_page += std::to_string(webua->cam->cfg->device_id); @@ -410,6 +662,98 @@ void cls_webu_json::status_vars(int indx_cam) webua->resp_page += ",\"user_pause\":\"" + cam->user_pause +"\""; + /* Add supportedControls for libcamera capability discovery */ + #ifdef HAVE_LIBCAM + if (cam->has_libcam()) { + webua->resp_page += ",\"supportedControls\":{"; + std::map caps = cam->get_libcam_capabilities(); + bool first = true; + for (const auto& [name, supported] : caps) { + if (!first) { + webua->resp_page += ","; + } + webua->resp_page += "\"" + name + "\":" + + (supported ? "true" : "false"); + first = false; + } + webua->resp_page += "}"; + } + #endif + + /* Add camera_type field */ + std::string type_str; + switch (cam->camera_type) { + case CAMERA_TYPE_LIBCAM: type_str = "libcam"; break; + case CAMERA_TYPE_V4L2: type_str = "v4l2"; break; + case CAMERA_TYPE_NETCAM: type_str = "netcam"; break; + default: type_str = "unknown"; break; + } + webua->resp_page += ",\"camera_type\":\"" + type_str + "\""; + + /* Add camera_device identifier */ + std::string device_str = ""; + if (cam->camera_type == CAMERA_TYPE_V4L2) { + device_str = cam->cfg->v4l2_device; + } else if (cam->camera_type == CAMERA_TYPE_NETCAM) { + device_str = cam->cfg->netcam_url; + } else if (cam->camera_type == CAMERA_TYPE_LIBCAM) { + device_str = cam->cfg->libcam_device; + } + webua->resp_page += ",\"camera_device\":\"" + escstr(device_str) + "\""; + + /* Add V4L2 controls array (if V4L2 camera) */ + #ifdef HAVE_V4L2 + if (cam->camera_type == CAMERA_TYPE_V4L2 && cam->has_v4l2()) { + vec_v4l2ctrl controls = cam->get_v4l2_controls(); + webua->resp_page += ",\"v4l2_controls\":["; + bool first_ctrl = true; + for (const auto& ctrl : controls) { + if (ctrl.ctrl_menuitem) continue; // Skip menu items + if (!first_ctrl) webua->resp_page += ","; + webua->resp_page += "{"; + webua->resp_page += "\"name\":\"" + escstr(ctrl.ctrl_name) + "\""; + webua->resp_page += ",\"id\":\"" + escstr(ctrl.ctrl_iddesc) + "\""; + // Map V4L2 control type to string + std::string ctrl_type_str; + if (ctrl.ctrl_type == V4L2_CTRL_TYPE_BOOLEAN) { + ctrl_type_str = "boolean"; + } else if (ctrl.ctrl_type == V4L2_CTRL_TYPE_MENU) { + ctrl_type_str = "menu"; + } else { + ctrl_type_str = "integer"; + } + webua->resp_page += ",\"type\":\"" + ctrl_type_str + "\""; + webua->resp_page += ",\"min\":" + std::to_string(ctrl.ctrl_minimum); + webua->resp_page += ",\"max\":" + std::to_string(ctrl.ctrl_maximum); + webua->resp_page += ",\"default\":" + std::to_string(ctrl.ctrl_default); + webua->resp_page += ",\"current\":" + std::to_string(ctrl.ctrl_currval); + webua->resp_page += "}"; + first_ctrl = false; + } + webua->resp_page += "]"; + } + #endif + + /* Add NETCAM status and high stream indicator (if NETCAM) */ + if (cam->camera_type == CAMERA_TYPE_NETCAM && cam->has_netcam()) { + std::string netcam_status_str; + switch (cam->netcam->status) { + case NETCAM_CONNECTED: netcam_status_str = "connected"; break; + case NETCAM_READINGIMAGE: netcam_status_str = "reading"; break; + case NETCAM_NOTCONNECTED: netcam_status_str = "not_connected"; break; + case NETCAM_RECONNECTING: netcam_status_str = "reconnecting"; break; + default: netcam_status_str = "unknown"; break; + } + webua->resp_page += ",\"netcam_status\":\"" + netcam_status_str + "\""; + + // Check if high resolution stream is configured + if (cam->has_netcam_high()) { + webua->resp_page += ",\"has_high_stream\":true"; + } else { + webua->resp_page += ",\"has_high_stream\":false"; + } + } + webua->resp_page += "}"; } @@ -471,6 +815,3166 @@ void cls_webu_json::loghistory() } +/* + * Hot Reload API: Validate parameter exists and is hot-reloadable + * Returns true if parameter can be hot-reloaded + * Sets parm_index to the index in config_parms[] (-1 if not found) + */ +bool cls_webu_json::validate_hot_reload(const std::string &parm_name, int &parm_index) +{ + parm_index = 0; + while (config_parms[parm_index].parm_name != "") { + if (config_parms[parm_index].parm_name == parm_name) { + /* Check permission level */ + if (config_parms[parm_index].webui_level > app->cfg->webcontrol_parms) { + return false; + } + /* Check hot reload flag */ + return config_parms[parm_index].hot_reload; + } + parm_index++; + } + parm_index = -1; /* Not found */ + return false; +} + +/* + * Hot Reload Helper: Apply hot-reloadable parameter to a specific camera + * Uses lookup table for O(1) dispatch instead of if/else chain + */ +void cls_webu_json::apply_hot_reload_to_camera(cls_camera *cam, + const std::string &parm_name, const std::string &parm_val) +{ + auto it = hot_reload_map.find(parm_name); + if (it != hot_reload_map.end()) { + it->second(cam, parm_val); + } +} + +/* + * Hot Reload API: Apply parameter change to config + */ +void cls_webu_json::apply_hot_reload(int parm_index, const std::string &parm_val) +{ + std::string parm_name = config_parms[parm_index].parm_name; + + if (webua->device_id == 0) { + /* Update default config */ + app->cfg->edit_set(parm_name, parm_val); + app->conf_src->edit_set(parm_name, parm_val); + + /* Update all running cameras - currently unreachable from UI but kept for + * future "Apply to All Cameras" feature and external API clients */ + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->cfg->edit_set(parm_name, parm_val); + app->cam_list[indx]->conf_src->edit_set(parm_name, parm_val); + apply_hot_reload_to_camera(app->cam_list[indx], parm_name, parm_val); + } + } else if (webua->cam != nullptr) { + /* Update specific camera only */ + webua->cam->cfg->edit_set(parm_name, parm_val); + webua->cam->conf_src->edit_set(parm_name, parm_val); + apply_hot_reload_to_camera(webua->cam, parm_name, parm_val); + } + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Hot reload: %s = %s (camera %d)", + parm_name.c_str(), parm_val.c_str(), webua->device_id); +} + + +/* + * External API: Authentication status for HTTP Basic/Digest clients + * GET /0/api/auth/me + * + * This endpoint is used by external API clients (curl, scripts, automation tools) + * that authenticate via HTTP Basic/Digest. The React UI uses /0/api/auth/status instead. + */ +void cls_webu_json::api_auth_me() +{ + webua->resp_page = "{"; + + /* Check if authentication is configured */ + if (app->cfg->webcontrol_authentication != "") { + webua->resp_page += "\"authenticated\":true,"; + webua->resp_page += "\"auth_method\":\"digest\","; + + /* Include role from HTTP Basic/Digest auth */ + if (webua->auth_role != "") { + webua->resp_page += "\"role\":\"" + webua->auth_role + "\""; + } else { + /* Default to admin if role not determined */ + webua->resp_page += "\"role\":\"admin\""; + } + } else { + webua->resp_page += "\"authenticated\":false"; + } + + webua->resp_page += "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Login with session creation + * POST /0/api/auth/login + * Body: {username, password} + * Returns: {session_token, csrf_token, role, expires_in} + */ +void cls_webu_json::api_auth_login() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Only accept POST */ + if (webua->get_method() != WEBUI_METHOD_POST) { + webua->resp_page = "{\"error\":\"Method not allowed\"}"; + webua->resp_code = 405; + return; + } + + /* Parse JSON body for username/password */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + webua->resp_page = "{\"error\":\"Invalid JSON\"}"; + webua->resp_code = 400; + return; + } + + std::string username = parser.getString("username"); + std::string password = parser.getString("password"); + + if (username.empty() || password.empty()) { + webua->resp_page = "{\"error\":\"Missing username or password\"}"; + webua->resp_code = 400; + return; + } + + /* Validate credentials against config */ + std::string role = ""; + + /* Check admin credentials */ + std::string admin_auth = app->cfg->webcontrol_authentication; + if (!admin_auth.empty()) { + size_t colon_pos = admin_auth.find(':'); + if (colon_pos != std::string::npos) { + std::string admin_user = admin_auth.substr(0, colon_pos); + std::string stored_value = admin_auth.substr(colon_pos + 1); + + /* Verify username matches */ + if (username == admin_user) { + /* Check if stored value is bcrypt hash or plaintext */ + if (cls_webu_auth::is_bcrypt_hash(stored_value)) { + /* Bcrypt hash - verify password */ + if (cls_webu_auth::verify_password(password, stored_value)) { + role = "admin"; + } + } else { + /* Plaintext password (for initial setup compatibility) */ + if (password == stored_value) { + role = "admin"; + + /* Log warning about plaintext password */ + MOTION_LOG(WRN, TYPE_ALL, NO_ERRNO, + "Plaintext admin password detected - " + "run motion-setup to hash credentials"); + } + } + } + } + } + + /* Check user credentials if admin didn't match */ + if (role.empty()) { + std::string user_auth = app->cfg->webcontrol_user_authentication; + if (!user_auth.empty()) { + size_t colon_pos = user_auth.find(':'); + if (colon_pos != std::string::npos) { + std::string user_user = user_auth.substr(0, colon_pos); + std::string stored_value = user_auth.substr(colon_pos + 1); + + /* Verify username matches */ + if (username == user_user) { + /* Check if stored value is bcrypt hash or plaintext */ + if (cls_webu_auth::is_bcrypt_hash(stored_value)) { + /* Bcrypt hash - verify password */ + if (cls_webu_auth::verify_password(password, stored_value)) { + role = "user"; + } + } else { + /* Plaintext password (for initial setup compatibility) */ + if (password == stored_value) { + role = "user"; + + /* Log warning about plaintext password */ + MOTION_LOG(WRN, TYPE_ALL, NO_ERRNO, + "Plaintext viewer password detected - " + "run motion-setup to hash credentials"); + } + } + } + } + } + } + + if (role.empty()) { + /* Log failed attempt for rate limiting */ + webua->failauth_log(true, username); + + webua->resp_page = "{\"error\":\"Invalid credentials\"}"; + webua->resp_code = 401; + return; + } + + /* Create session */ + std::string session_token = webu->session_create(role, webua->clientip); + std::string csrf_token = webu->session_get_csrf(session_token); + + /* Return session info */ + webua->resp_page = "{"; + webua->resp_page += "\"session_token\":\"" + session_token + "\","; + webua->resp_page += "\"csrf_token\":\"" + csrf_token + "\","; + webua->resp_page += "\"role\":\"" + role + "\","; + webua->resp_page += "\"expires_in\":" + std::to_string(app->cfg->webcontrol_session_timeout); + webua->resp_page += "}"; +} + +/* + * React UI API: Logout (destroy session) + * POST /0/api/auth/logout + */ +void cls_webu_json::api_auth_logout() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (webua->get_method() != WEBUI_METHOD_POST) { + webua->resp_page = "{\"error\":\"Method not allowed\"}"; + webua->resp_code = 405; + return; + } + + /* Get session token from header */ + std::string session_token = webua->session_token; + + if (!session_token.empty()) { + webu->session_destroy(session_token); + } + + webua->resp_page = "{\"success\":true}"; +} + +/* + * React UI API: Get authentication status + * GET /0/api/auth/status + * Returns: {auth_required, authenticated, role?, csrf_token?} + */ +void cls_webu_json::api_auth_status() +{ + webua->resp_type = WEBUI_RESP_JSON; + webua->resp_page = "{"; + + /* Check if authentication is configured */ + bool auth_required = (app->cfg->webcontrol_authentication != ""); + + webua->resp_page += "\"auth_required\":" + std::string(auth_required ? "true" : "false"); + + if (!auth_required) { + /* No auth configured - full access with pseudo-session for CSRF protection */ + /* Create or reuse session for CSRF token even when auth not required */ + if (webua->session_token.empty()) { + /* No session yet - create pseudo-session for CSRF */ + std::string new_token = webu->session_create("admin", webua->clientip); + webua->resp_page += ",\"authenticated\":true"; + webua->resp_page += ",\"role\":\"admin\""; + webua->resp_page += ",\"session_token\":\"" + new_token + "\""; + webua->resp_page += ",\"csrf_token\":\"" + webu->session_get_csrf(new_token) + "\""; + } else { + /* Reuse existing session */ + std::string role = webu->session_validate(webua->session_token, webua->clientip); + if (!role.empty()) { + webua->resp_page += ",\"authenticated\":true"; + webua->resp_page += ",\"role\":\"" + role + "\""; + webua->resp_page += ",\"csrf_token\":\"" + webu->session_get_csrf(webua->session_token) + "\""; + } else { + /* Session expired - create new one */ + std::string new_token = webu->session_create("admin", webua->clientip); + webua->resp_page += ",\"authenticated\":true"; + webua->resp_page += ",\"role\":\"admin\""; + webua->resp_page += ",\"session_token\":\"" + new_token + "\""; + webua->resp_page += ",\"csrf_token\":\"" + webu->session_get_csrf(new_token) + "\""; + } + } + } else if (!webua->session_token.empty()) { + /* Session token provided - validate it */ + std::string role = webu->session_validate( + webua->session_token, webua->clientip); + + if (!role.empty()) { + webua->resp_page += ",\"authenticated\":true"; + webua->resp_page += ",\"role\":\"" + role + "\""; + webua->resp_page += ",\"csrf_token\":\"" + + webu->session_get_csrf(webua->session_token) + "\""; + } else { + webua->resp_page += ",\"authenticated\":false"; + } + } else if (!webua->auth_role.empty()) { + /* HTTP Basic/Digest auth for external API clients (curl, scripts, etc.) */ + webua->resp_page += ",\"authenticated\":true"; + webua->resp_page += ",\"role\":\"" + webua->auth_role + "\""; + webua->resp_page += ",\"csrf_token\":\"" + webu->csrf_token + "\""; + } else { + /* Auth required but no credentials */ + webua->resp_page += ",\"authenticated\":false"; + } + + webua->resp_page += "}"; +} + +/* + * React UI API: Media pictures list + * Returns list of snapshot images for a camera + */ +void cls_webu_json::api_media_pictures() +{ + vec_files flst, flst_count; + std::string sql, where_clause; + int offset = 0, limit = 100; + int64_t total_count = 0; + const char* date_filter = nullptr; + + if (webua->cam == nullptr) { + webua->bad_request(); + return; + } + + /* Parse query parameters */ + const char* offset_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "offset"); + const char* limit_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "limit"); + date_filter = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "date"); + + if (offset_str) offset = std::max(0, atoi(offset_str)); + if (limit_str) limit = std::min(std::max(1, atoi(limit_str)), 100); // Cap at 100 + + /* Build WHERE clause */ + where_clause = " where device_id = " + std::to_string(webua->cam->cfg->device_id); + where_clause += " and file_typ = 'pic'"; + if (date_filter && strlen(date_filter) == 8) { + where_clause += " and file_dtl = " + std::string(date_filter); + } + + /* Get total count - query just record_id for efficiency */ + sql = " select record_id from motion " + where_clause + ";"; + app->dbse->filelist_get(sql, flst_count); + total_count = flst_count.size(); + + /* Get paginated results */ + sql = " select * from motion "; + sql += where_clause; + sql += " order by file_dtl desc, file_tml desc"; + sql += " limit " + std::to_string(limit); + sql += " offset " + std::to_string(offset) + ";"; + + app->dbse->filelist_get(sql, flst); + + /* Build JSON response with pagination metadata */ + webua->resp_page = "{"; + webua->resp_page += "\"total_count\":" + std::to_string(total_count) + ","; + webua->resp_page += "\"offset\":" + std::to_string(offset) + ","; + webua->resp_page += "\"limit\":" + std::to_string(limit) + ","; + webua->resp_page += "\"date_filter\":"; + if (date_filter) { + webua->resp_page += "\"" + std::string(date_filter) + "\""; + } else { + webua->resp_page += "null"; + } + webua->resp_page += ",\"pictures\":["; + + for (size_t i = 0; i < flst.size(); i++) { + if (i > 0) webua->resp_page += ","; + webua->resp_page += "{"; + webua->resp_page += "\"id\":" + std::to_string(flst[i].record_id) + ","; + webua->resp_page += "\"filename\":\"" + escstr(flst[i].file_nm) + "\","; + webua->resp_page += "\"path\":\"" + escstr(flst[i].full_nm) + "\","; + webua->resp_page += "\"date\":\"" + std::to_string(flst[i].file_dtl) + "\","; + webua->resp_page += "\"time\":\"" + escstr(flst[i].file_tml) + "\","; + webua->resp_page += "\"size\":" + std::to_string(flst[i].file_sz); + webua->resp_page += "}"; + } + webua->resp_page += "]}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Delete a picture file + * DELETE /{camId}/api/media/picture/{id} + * Deletes both the file and database record + */ +void cls_webu_json::api_delete_picture() +{ + int indx; + std::string sql, full_path; + vec_files flst; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Check if delete action is enabled */ + for (indx=0; indxwb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "delete") { + if (webu->wb_actions->params_array[indx].param_value == "off") { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Delete action disabled"); + webua->resp_page = "{\"error\":\"Delete action is disabled\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + break; + } + } + + /* Get file ID from URI: uri_cmd4 contains the record ID */ + if (webua->uri_cmd4.empty()) { + webua->resp_page = "{\"error\":\"File ID required\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + int file_id = mtoi(webua->uri_cmd4); + if (file_id <= 0) { + webua->resp_page = "{\"error\":\"Invalid file ID\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Look up the file in database */ + sql = " select * from motion "; + sql += " where record_id = " + std::to_string(file_id); + sql += " and device_id = " + std::to_string(webua->cam->cfg->device_id); + sql += " and file_typ = 'pic'"; + app->dbse->filelist_get(sql, flst); + + if (flst.empty()) { + webua->resp_page = "{\"error\":\"File not found\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Security: Validate file path to prevent directory traversal */ + full_path = flst[0].full_nm; + if (full_path.find("..") != std::string::npos) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Path traversal attempt blocked: %s from %s"), + full_path.c_str(), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Invalid file path\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Delete the file from filesystem */ + if (remove(full_path.c_str()) != 0 && errno != ENOENT) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Failed to delete file: %s"), full_path.c_str()); + webua->resp_page = "{\"error\":\"Failed to delete file\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Delete from database */ + sql = "delete from motion where record_id = " + std::to_string(file_id); + app->dbse->exec_sql(sql); + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Deleted picture: %s (id=%d) by %s", + flst[0].file_nm.c_str(), file_id, webua->clientip.c_str()); + + webua->resp_page = "{\"success\":true,\"deleted_id\":" + std::to_string(file_id) + "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Delete a movie file + * DELETE /{camId}/api/media/movie/{id} + * Deletes both the file and database record + */ +void cls_webu_json::api_delete_movie() +{ + int indx; + std::string sql, full_path; + vec_files flst; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Check if delete action is enabled */ + for (indx=0; indxwb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "delete") { + if (webu->wb_actions->params_array[indx].param_value == "off") { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Delete action disabled"); + webua->resp_page = "{\"error\":\"Delete action is disabled\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + break; + } + } + + /* Get file ID from URI: uri_cmd4 contains the record ID */ + if (webua->uri_cmd4.empty()) { + webua->resp_page = "{\"error\":\"File ID required\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + int file_id = mtoi(webua->uri_cmd4); + if (file_id <= 0) { + webua->resp_page = "{\"error\":\"Invalid file ID\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Look up the file in database */ + sql = " select * from motion "; + sql += " where record_id = " + std::to_string(file_id); + sql += " and device_id = " + std::to_string(webua->cam->cfg->device_id); + sql += " and file_typ = 'movie'"; + app->dbse->filelist_get(sql, flst); + + if (flst.empty()) { + webua->resp_page = "{\"error\":\"File not found\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Security: Validate file path to prevent directory traversal */ + full_path = flst[0].full_nm; + if (full_path.find("..") != std::string::npos) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Path traversal attempt blocked: %s from %s"), + full_path.c_str(), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Invalid file path\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Delete the file from filesystem */ + if (remove(full_path.c_str()) != 0 && errno != ENOENT) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Failed to delete file: %s"), full_path.c_str()); + webua->resp_page = "{\"error\":\"Failed to delete file\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Delete associated thumbnail */ + std::string thumb_path = full_path + ".thumb.jpg"; + if (remove(thumb_path.c_str()) != 0 && errno != ENOENT) { + MOTION_LOG(NTC, TYPE_STREAM, SHOW_ERRNO, + _("Could not delete thumbnail: %s"), thumb_path.c_str()); + /* Non-fatal - continue with database deletion */ + } + + /* Delete from database */ + sql = "delete from motion where record_id = " + std::to_string(file_id); + app->dbse->exec_sql(sql); + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Deleted movie: %s (id=%d) by %s", + flst[0].file_nm.c_str(), file_id, webua->clientip.c_str()); + + webua->resp_page = "{\"success\":true,\"deleted_id\":" + std::to_string(file_id) + "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: List movies + * Returns list of movie files from database + */ +void cls_webu_json::api_media_movies() +{ + vec_files flst, flst_count; + std::string sql, where_clause, cam_id; + int offset = 0, limit = 100; + int64_t total_count = 0; + const char* date_filter = nullptr; + + if (webua->cam == nullptr) { + webua->bad_request(); + return; + } + + cam_id = std::to_string(webua->cam->cfg->device_id); + + /* Parse query parameters */ + const char* offset_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "offset"); + const char* limit_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "limit"); + date_filter = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "date"); + + if (offset_str) offset = std::max(0, atoi(offset_str)); + if (limit_str) limit = std::min(std::max(1, atoi(limit_str)), 100); // Cap at 100 + + /* Build WHERE clause */ + where_clause = " where device_id = " + cam_id; + where_clause += " and file_typ = 'movie'"; + if (date_filter && strlen(date_filter) == 8) { + where_clause += " and file_dtl = " + std::string(date_filter); + } + + /* Get total count - query just record_id for efficiency */ + sql = " select record_id from motion " + where_clause + ";"; + app->dbse->filelist_get(sql, flst_count); + total_count = flst_count.size(); + + /* Get paginated results */ + sql = " select * from motion "; + sql += where_clause; + sql += " order by file_dtl desc, file_tml desc"; + sql += " limit " + std::to_string(limit); + sql += " offset " + std::to_string(offset) + ";"; + + app->dbse->filelist_get(sql, flst); + + /* Build JSON response with pagination metadata */ + webua->resp_page = "{"; + webua->resp_page += "\"total_count\":" + std::to_string(total_count) + ","; + webua->resp_page += "\"offset\":" + std::to_string(offset) + ","; + webua->resp_page += "\"limit\":" + std::to_string(limit) + ","; + webua->resp_page += "\"date_filter\":"; + if (date_filter) { + webua->resp_page += "\"" + std::string(date_filter) + "\""; + } else { + webua->resp_page += "null"; + } + webua->resp_page += ",\"movies\":["; + + for (size_t i = 0; i < flst.size(); i++) { + if (i > 0) webua->resp_page += ","; + webua->resp_page += "{"; + webua->resp_page += "\"id\":" + std::to_string(flst[i].record_id) + ","; + webua->resp_page += "\"filename\":\"" + escstr(flst[i].file_nm) + "\","; + /* Return URL path for browser access, not filesystem path */ + webua->resp_page += "\"path\":\"/" + cam_id + "/movies/" + escstr(flst[i].file_nm) + "\","; + webua->resp_page += "\"date\":\"" + std::to_string(flst[i].file_dtl) + "\","; + webua->resp_page += "\"time\":\"" + escstr(flst[i].file_tml) + "\","; + webua->resp_page += "\"size\":" + std::to_string(flst[i].file_sz); + + /* Add thumbnail path if exists */ + std::string thumb_path = flst[i].full_nm + ".thumb.jpg"; + struct stat st; + if (stat(thumb_path.c_str(), &st) == 0) { + webua->resp_page += ",\"thumbnail\":\"/" + cam_id + "/movies/" + + escstr(flst[i].file_nm) + ".thumb.jpg\""; + } + + webua->resp_page += "}"; + } + webua->resp_page += "]}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Date summary + * Returns list of dates with counts for a media type + * GET /{camId}/api/media/dates?type=movie + */ +void cls_webu_json::api_media_dates() +{ + vec_files flst; + std::string sql, file_typ; + std::map date_counts; + int64_t total_count = 0; + const char* type_param; + + if (webua->cam == nullptr) { + webua->bad_request(); + return; + } + + /* Parse type parameter (required) */ + type_param = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "type"); + + if (!type_param || (strcmp(type_param, "pic") != 0 && strcmp(type_param, "movie") != 0)) { + webua->resp_page = "{\"error\":\"Invalid or missing 'type' parameter. Must be 'pic' or 'movie'\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + file_typ = type_param; + + /* Query all records for this type to build date summary */ + sql = " select record_id, file_dtl from motion "; + sql += " where device_id = " + std::to_string(webua->cam->cfg->device_id); + sql += " and file_typ = '" + file_typ + "'"; + sql += " order by file_dtl desc;"; + + app->dbse->filelist_get(sql, flst); + total_count = flst.size(); + + /* Group by date */ + for (size_t i = 0; i < flst.size(); i++) { + std::string date_str = std::to_string(flst[i].file_dtl); + date_counts[date_str]++; + } + + /* Build JSON response */ + webua->resp_page = "{"; + webua->resp_page += "\"type\":\"" + file_typ + "\","; + webua->resp_page += "\"total_count\":" + std::to_string(total_count) + ","; + webua->resp_page += "\"dates\":["; + + bool first = true; + for (const auto& pair : date_counts) { + if (!first) webua->resp_page += ","; + webua->resp_page += "{"; + webua->resp_page += "\"date\":\"" + pair.first + "\","; + webua->resp_page += "\"count\":" + std::to_string(pair.second); + webua->resp_page += "}"; + first = false; + } + + webua->resp_page += "]}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* Media file extension checking helpers */ +static bool is_media_extension(const std::string &ext) +{ + static const std::set media_exts = { + ".mp4", ".mkv", ".avi", ".webm", ".mov", + ".jpg", ".jpeg", ".png", ".gif", ".bmp" + }; + std::string lower_ext = ext; + std::transform(lower_ext.begin(), lower_ext.end(), lower_ext.begin(), ::tolower); + return media_exts.find(lower_ext) != media_exts.end(); +} + +static bool is_thumbnail(const std::string &filename) +{ + return filename.length() > 10 && + filename.substr(filename.length() - 10) == ".thumb.jpg"; +} + +static std::string get_file_extension(const std::string &filename) +{ + size_t dot_pos = filename.rfind('.'); + if (dot_pos == std::string::npos || dot_pos == 0) return ""; + return filename.substr(dot_pos); +} + +/* Validate path is safe (no traversal, within target_dir) */ +static bool validate_folder_path(const std::string &target_dir, const std::string &rel_path, + std::string &full_path) +{ + /* Check for path traversal attempts */ + if (rel_path.find("..") != std::string::npos) { + return false; + } + + /* Build full path */ + full_path = target_dir; + if (!full_path.empty() && full_path.back() != '/') { + full_path += '/'; + } + if (!rel_path.empty()) { + full_path += rel_path; + } + + /* Resolve symlinks and check real path is still under target_dir */ + char resolved[PATH_MAX]; + if (realpath(full_path.c_str(), resolved) == nullptr) { + /* Path doesn't exist - that's ok for empty folder case */ + return true; + } + + std::string real_path(resolved); + char target_resolved[PATH_MAX]; + if (realpath(target_dir.c_str(), target_resolved) == nullptr) { + return false; + } + std::string real_target(target_resolved); + + /* Ensure resolved path starts with target_dir */ + if (real_path.length() < real_target.length() || + real_path.substr(0, real_target.length()) != real_target) { + return false; + } + + /* Ensure it's either exactly target_dir or has a / separator after */ + if (real_path.length() > real_target.length() && + real_path[real_target.length()] != '/') { + return false; + } + + return true; +} + +/* + * React UI API: Folder-based media browsing + * GET /{camId}/api/media/folders?path=rel/path&offset=0&limit=100 + * Returns folders and media files in the specified directory + */ +void cls_webu_json::api_media_folders() +{ + vec_files flst; + std::string sql, target_dir, full_path; + int offset = 0, limit = 100; + const char* path_param = nullptr; + std::string rel_path; + + if (webua->cam == nullptr) { + webua->bad_request(); + return; + } + + /* Get target directory for this camera */ + target_dir = webua->cam->cfg->target_dir; + if (target_dir.empty()) { + webua->resp_page = "{\"error\":\"Target directory not configured\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Parse query parameters */ + path_param = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "path"); + const char* offset_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "offset"); + const char* limit_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "limit"); + + if (path_param) rel_path = path_param; + if (offset_str) offset = std::max(0, atoi(offset_str)); + if (limit_str) limit = std::min(std::max(1, atoi(limit_str)), 100); + + /* Validate and build full path */ + if (!validate_folder_path(target_dir, rel_path, full_path)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Path traversal attempt blocked: %s from %s"), + rel_path.c_str(), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Invalid path\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Open directory */ + DIR *dir = opendir(full_path.c_str()); + if (dir == nullptr) { + webua->resp_page = "{\"error\":\"Directory not found\"}"; + webua->resp_type = WEBUI_RESP_JSON; + return; + } + + /* Scan directory entries */ + struct dirent *entry; + std::vector> folders; /* name, path */ + std::vector media_files; + + while ((entry = readdir(dir)) != nullptr) { + std::string name = entry->d_name; + + /* Skip . and .. */ + if (name == "." || name == "..") continue; + + /* Skip hidden files */ + if (name[0] == '.') continue; + + std::string entry_path = full_path + "/" + name; + struct stat st; + if (stat(entry_path.c_str(), &st) != 0) continue; + + if (S_ISDIR(st.st_mode)) { + /* Directory - add to folders list */ + std::string folder_rel = rel_path.empty() ? name : rel_path + "/" + name; + folders.push_back({name, folder_rel}); + } else if (S_ISREG(st.st_mode)) { + /* Regular file - check if it's a media file (not thumbnail) */ + std::string ext = get_file_extension(name); + if (is_media_extension(ext) && !is_thumbnail(name)) { + media_files.push_back(name); + } + } + } + closedir(dir); + + /* Sort folders and files alphabetically */ + std::sort(folders.begin(), folders.end()); + std::sort(media_files.begin(), media_files.end()); + + /* Calculate folder statistics (file count, total size) */ + std::string cam_id = std::to_string(webua->cam->cfg->device_id); + + /* Build JSON response */ + webua->resp_page = "{"; + webua->resp_page += "\"path\":\"" + escstr(rel_path) + "\","; + + /* Parent path for navigation */ + if (rel_path.empty()) { + webua->resp_page += "\"parent\":null,"; + } else { + size_t last_slash = rel_path.rfind('/'); + std::string parent = (last_slash == std::string::npos) ? "" : rel_path.substr(0, last_slash); + webua->resp_page += "\"parent\":\"" + escstr(parent) + "\","; + } + + /* Folders */ + webua->resp_page += "\"folders\":["; + for (size_t i = 0; i < folders.size(); i++) { + if (i > 0) webua->resp_page += ","; + + /* Count files in this folder (from database) */ + std::string folder_path = full_path + "/" + folders[i].first; + int64_t file_count = 0; + int64_t total_size = 0; + + /* Count by scanning directory */ + DIR *subdir = opendir(folder_path.c_str()); + if (subdir != nullptr) { + struct dirent *subentry; + while ((subentry = readdir(subdir)) != nullptr) { + std::string subname = subentry->d_name; + if (subname == "." || subname == "..") continue; + std::string subpath = folder_path + "/" + subname; + struct stat sub_st; + if (stat(subpath.c_str(), &sub_st) == 0 && S_ISREG(sub_st.st_mode)) { + std::string ext = get_file_extension(subname); + if (is_media_extension(ext) && !is_thumbnail(subname)) { + file_count++; + total_size += sub_st.st_size; + } + } + } + closedir(subdir); + } + + webua->resp_page += "{"; + webua->resp_page += "\"name\":\"" + escstr(folders[i].first) + "\","; + webua->resp_page += "\"path\":\"" + escstr(folders[i].second) + "\","; + webua->resp_page += "\"file_count\":" + std::to_string(file_count) + ","; + webua->resp_page += "\"total_size\":" + std::to_string(total_size); + webua->resp_page += "}"; + } + webua->resp_page += "],"; + + /* Files with pagination */ + int total_files = (int)media_files.size(); + int start_idx = std::min(offset, total_files); + int end_idx = std::min(offset + limit, total_files); + + webua->resp_page += "\"files\":["; + for (int i = start_idx; i < end_idx; i++) { + if (i > start_idx) webua->resp_page += ","; + + std::string filename = media_files[i]; + std::string file_path = full_path + "/" + filename; + struct stat st; + stat(file_path.c_str(), &st); + + /* Determine file type */ + std::string ext = get_file_extension(filename); + std::string file_type = "movie"; + if (ext == ".jpg" || ext == ".jpeg" || ext == ".png" || + ext == ".gif" || ext == ".bmp") { + file_type = "picture"; + } + + /* Look up in database for metadata */ + sql = " select * from motion "; + sql += " where device_id = " + cam_id; + sql += " and file_nm = '" + filename + "'"; + sql += " limit 1;"; + flst.clear(); + app->dbse->filelist_get(sql, flst); + + webua->resp_page += "{"; + + if (!flst.empty()) { + webua->resp_page += "\"id\":" + std::to_string(flst[0].record_id) + ","; + webua->resp_page += "\"date\":\"" + std::to_string(flst[0].file_dtl) + "\","; + webua->resp_page += "\"time\":\"" + escstr(flst[0].file_tml) + "\","; + } else { + webua->resp_page += "\"id\":0,"; + /* Extract date from filename if possible (common format: camera-YYYYMMDD...) */ + webua->resp_page += "\"date\":\"\","; + webua->resp_page += "\"time\":\"\","; + } + + webua->resp_page += "\"filename\":\"" + escstr(filename) + "\","; + + /* Build URL path for access */ + if (file_type == "movie") { + std::string url_path = "/" + cam_id + "/movies/"; + if (!rel_path.empty()) url_path += rel_path + "/"; + url_path += filename; + webua->resp_page += "\"path\":\"" + escstr(url_path) + "\","; + + /* Check for thumbnail */ + std::string thumb_file = file_path + ".thumb.jpg"; + struct stat thumb_st; + if (stat(thumb_file.c_str(), &thumb_st) == 0) { + webua->resp_page += "\"thumbnail\":\"" + escstr(url_path + ".thumb.jpg") + "\","; + } + } else { + /* Pictures use direct file path */ + webua->resp_page += "\"path\":\"" + escstr(file_path) + "\","; + } + + webua->resp_page += "\"type\":\"" + file_type + "\","; + webua->resp_page += "\"size\":" + std::to_string(st.st_size); + webua->resp_page += "}"; + } + webua->resp_page += "],"; + + webua->resp_page += "\"total_files\":" + std::to_string(total_files) + ","; + webua->resp_page += "\"offset\":" + std::to_string(offset) + ","; + webua->resp_page += "\"limit\":" + std::to_string(limit); + webua->resp_page += "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Delete all media files in a folder + * DELETE /{camId}/api/media/folders/files?path=rel/path + * Deletes media files only (not subfolders or non-media files) + * Also deletes associated thumbnails + */ +void cls_webu_json::api_delete_folder_files() +{ + std::string target_dir, full_path, sql; + const char* path_param = nullptr; + std::string rel_path; + int deleted_movies = 0, deleted_pictures = 0, deleted_thumbnails = 0; + std::vector errors; + + webua->resp_type = WEBUI_RESP_JSON; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + return; + } + + /* Require admin role */ + if (webua->auth_role != "admin") { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Delete folder files denied - requires admin role (from %s)"), + webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Admin access required\"}"; + return; + } + + /* Check if delete action is enabled */ + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "delete") { + if (webu->wb_actions->params_array[indx].param_value == "off") { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Delete action disabled"); + webua->resp_page = "{\"error\":\"Delete action is disabled\"}"; + return; + } + break; + } + } + + /* Get path parameter (required) */ + path_param = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "path"); + + if (path_param == nullptr) { + webua->resp_page = "{\"error\":\"Path parameter required\"}"; + return; + } + rel_path = path_param; + + /* Get target directory for this camera */ + target_dir = webua->cam->cfg->target_dir; + if (target_dir.empty()) { + webua->resp_page = "{\"error\":\"Target directory not configured\"}"; + return; + } + + /* Validate and build full path */ + if (!validate_folder_path(target_dir, rel_path, full_path)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Path traversal attempt blocked: %s from %s"), + rel_path.c_str(), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Invalid path\"}"; + return; + } + + /* Open directory */ + DIR *dir = opendir(full_path.c_str()); + if (dir == nullptr) { + webua->resp_page = "{\"error\":\"Directory not found\"}"; + return; + } + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Delete all media files in folder '%s' requested by %s", + rel_path.c_str(), webua->clientip.c_str()); + + /* Collect media files to delete */ + struct dirent *entry; + std::vector files_to_delete; + std::vector thumbs_to_delete; + + while ((entry = readdir(dir)) != nullptr) { + std::string name = entry->d_name; + if (name == "." || name == "..") continue; + + std::string entry_path = full_path + "/" + name; + struct stat st; + if (stat(entry_path.c_str(), &st) != 0) continue; + + if (S_ISREG(st.st_mode)) { + std::string ext = get_file_extension(name); + if (is_thumbnail(name)) { + /* Track thumbnails separately - they'll be deleted with their movie */ + continue; + } else if (is_media_extension(ext)) { + files_to_delete.push_back(entry_path); + /* Check for associated thumbnail */ + std::string thumb_path = entry_path + ".thumb.jpg"; + struct stat thumb_st; + if (stat(thumb_path.c_str(), &thumb_st) == 0) { + thumbs_to_delete.push_back(thumb_path); + } + } + } + } + closedir(dir); + + std::string cam_id = std::to_string(webua->cam->cfg->device_id); + + /* Delete files */ + for (const auto& file_path : files_to_delete) { + std::string ext = get_file_extension(file_path); + bool is_movie = (ext == ".mp4" || ext == ".mkv" || ext == ".avi" || + ext == ".webm" || ext == ".mov"); + + if (remove(file_path.c_str()) == 0) { + if (is_movie) { + deleted_movies++; + } else { + deleted_pictures++; + } + + /* Delete from database */ + size_t last_slash = file_path.rfind('/'); + std::string filename = (last_slash == std::string::npos) ? + file_path : file_path.substr(last_slash + 1); + + sql = "delete from motion where device_id = " + cam_id + + " and file_nm = '" + filename + "'"; + app->dbse->exec_sql(sql); + } else { + errors.push_back("Failed to delete: " + file_path); + MOTION_LOG(ERR, TYPE_STREAM, SHOW_ERRNO, + _("Failed to delete file: %s"), file_path.c_str()); + } + } + + /* Delete thumbnails */ + for (const auto& thumb_path : thumbs_to_delete) { + if (remove(thumb_path.c_str()) == 0) { + deleted_thumbnails++; + } + } + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Deleted %d movies, %d pictures, %d thumbnails from '%s'", + deleted_movies, deleted_pictures, deleted_thumbnails, rel_path.c_str()); + + /* Build response */ + webua->resp_page = "{"; + webua->resp_page += "\"success\":true,"; + webua->resp_page += "\"deleted\":{"; + webua->resp_page += "\"movies\":" + std::to_string(deleted_movies) + ","; + webua->resp_page += "\"pictures\":" + std::to_string(deleted_pictures) + ","; + webua->resp_page += "\"thumbnails\":" + std::to_string(deleted_thumbnails); + webua->resp_page += "},"; + webua->resp_page += "\"errors\":["; + for (size_t i = 0; i < errors.size(); i++) { + if (i > 0) webua->resp_page += ","; + webua->resp_page += "\"" + escstr(errors[i]) + "\""; + } + webua->resp_page += "],"; + webua->resp_page += "\"path\":\"" + escstr(rel_path) + "\""; + webua->resp_page += "}"; +} + +/* + * React UI API: System temperature + * Returns CPU temperature (Raspberry Pi) + */ +void cls_webu_json::api_system_temperature() +{ + FILE *temp_file; + int temp_raw; + double temp_celsius; + + webua->resp_page = "{"; + + temp_file = fopen("/sys/class/thermal/thermal_zone0/temp", "r"); + if (temp_file != nullptr) { + if (fscanf(temp_file, "%d", &temp_raw) == 1) { + temp_celsius = temp_raw / 1000.0; + webua->resp_page += "\"celsius\":" + std::to_string(temp_celsius) + ","; + webua->resp_page += "\"fahrenheit\":" + std::to_string(temp_celsius * 9.0 / 5.0 + 32.0); + } + fclose(temp_file); + } else { + webua->resp_page += "\"error\":\"Temperature not available\""; + } + + webua->resp_page += "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: System status + * Returns comprehensive system information (CPU temp, disk, memory, uptime) + */ +void cls_webu_json::api_system_status() +{ + FILE *file; + char buffer[256]; + int temp_raw; + double temp_celsius; + unsigned long uptime_sec, mem_total, mem_free, mem_available; + struct statvfs fs_stat; + + webua->resp_page = "{"; + + /* CPU Temperature */ + file = fopen("/sys/class/thermal/thermal_zone0/temp", "r"); + if (file != nullptr) { + if (fscanf(file, "%d", &temp_raw) == 1) { + temp_celsius = temp_raw / 1000.0; + webua->resp_page += "\"temperature\":{"; + webua->resp_page += "\"celsius\":" + std::to_string(temp_celsius) + ","; + webua->resp_page += "\"fahrenheit\":" + std::to_string(temp_celsius * 9.0 / 5.0 + 32.0); + webua->resp_page += "},"; + } + fclose(file); + } + + /* System Uptime */ + file = fopen("/proc/uptime", "r"); + if (file != nullptr) { + if (fscanf(file, "%lu", &uptime_sec) == 1) { + webua->resp_page += "\"uptime\":{"; + webua->resp_page += "\"seconds\":" + std::to_string(uptime_sec) + ","; + webua->resp_page += "\"days\":" + std::to_string(uptime_sec / 86400) + ","; + webua->resp_page += "\"hours\":" + std::to_string((uptime_sec % 86400) / 3600); + webua->resp_page += "},"; + } + fclose(file); + } + + /* Memory Information */ + file = fopen("/proc/meminfo", "r"); + if (file != nullptr) { + mem_total = mem_free = mem_available = 0; + while (fgets(buffer, sizeof(buffer), file)) { + if (sscanf(buffer, "MemTotal: %lu kB", &mem_total) == 1) continue; + if (sscanf(buffer, "MemFree: %lu kB", &mem_free) == 1) continue; + if (sscanf(buffer, "MemAvailable: %lu kB", &mem_available) == 1) break; + } + fclose(file); + + if (mem_total > 0) { + unsigned long mem_used = mem_total - mem_available; + double mem_percent = (double)mem_used / mem_total * 100.0; + webua->resp_page += "\"memory\":{"; + webua->resp_page += "\"total\":" + std::to_string(mem_total * 1024) + ","; + webua->resp_page += "\"used\":" + std::to_string(mem_used * 1024) + ","; + webua->resp_page += "\"free\":" + std::to_string(mem_free * 1024) + ","; + webua->resp_page += "\"available\":" + std::to_string(mem_available * 1024) + ","; + webua->resp_page += "\"percent\":" + std::to_string(mem_percent); + webua->resp_page += "},"; + } + } + + /* Disk Usage (root filesystem) */ + if (statvfs("/", &fs_stat) == 0) { + unsigned long long total_bytes = (unsigned long long)fs_stat.f_blocks * fs_stat.f_frsize; + unsigned long long free_bytes = (unsigned long long)fs_stat.f_bfree * fs_stat.f_frsize; + unsigned long long avail_bytes = (unsigned long long)fs_stat.f_bavail * fs_stat.f_frsize; + unsigned long long used_bytes = total_bytes - free_bytes; + double disk_percent = (double)used_bytes / total_bytes * 100.0; + + webua->resp_page += "\"disk\":{"; + webua->resp_page += "\"total\":" + std::to_string(total_bytes) + ","; + webua->resp_page += "\"used\":" + std::to_string(used_bytes) + ","; + webua->resp_page += "\"free\":" + std::to_string(free_bytes) + ","; + webua->resp_page += "\"available\":" + std::to_string(avail_bytes) + ","; + webua->resp_page += "\"percent\":" + std::to_string(disk_percent); + webua->resp_page += "},"; + } + + /* Device Model (Raspberry Pi) */ + file = fopen("/proc/device-tree/model", "r"); + if (file != nullptr) { + if (fgets(buffer, sizeof(buffer), file)) { + /* Remove trailing newline/null */ + size_t len = strlen(buffer); + while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\0' || buffer[len-1] == '\r')) { + buffer[--len] = '\0'; + } + webua->resp_page += "\"device_model\":\"" + escstr(buffer) + "\","; + + /* Detect Pi generation */ + if (strstr(buffer, "Pi 5") != nullptr) { + webua->resp_page += "\"pi_generation\":5,"; + } else if (strstr(buffer, "Pi 4") != nullptr) { + webua->resp_page += "\"pi_generation\":4,"; + } else if (strstr(buffer, "Pi 3") != nullptr) { + webua->resp_page += "\"pi_generation\":3,"; + } else { + webua->resp_page += "\"pi_generation\":0,"; + } + } + fclose(file); + } + + /* Hardware Encoder Availability */ + { + const AVCodec *codec_check; + webua->resp_page += "\"hardware_encoders\":{"; + + /* Check for V4L2 M2M H.264 encoder (Pi 4 only) */ + codec_check = avcodec_find_encoder_by_name("h264_v4l2m2m"); + webua->resp_page += "\"h264_v4l2m2m\":" + std::string(codec_check ? "true" : "false"); + + webua->resp_page += "},"; + } + + /* Webcontrol Actions Status */ + webua->resp_page += "\"actions\":{"; + + bool service_enabled = false; + bool power_enabled = false; + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "service" && + webu->wb_actions->params_array[indx].param_value == "on") { + service_enabled = true; + } + if (webu->wb_actions->params_array[indx].param_name == "power" && + webu->wb_actions->params_array[indx].param_value == "on") { + power_enabled = true; + } + } + + webua->resp_page += "\"service\":" + std::string(service_enabled ? "true" : "false"); + webua->resp_page += ",\"power\":" + std::string(power_enabled ? "true" : "false"); + webua->resp_page += "},"; + + /* Motion Version */ + webua->resp_page += "\"version\":\"" + escstr(VERSION) + "\""; + + /* Camera Status (includes FPS for each camera) */ + webua->resp_page += ",\"status\":{"; + webua->resp_page += "\"count\":" + std::to_string(app->cam_cnt); + for (int indx_cam = 0; indx_cam < app->cam_cnt; indx_cam++) { + webua->resp_page += ",\"cam" + + std::to_string(app->cam_list[indx_cam]->cfg->device_id) + "\":"; + status_vars(indx_cam); + } + webua->resp_page += "}"; + + webua->resp_page += "}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: System reboot + * POST /0/api/system/reboot + * Requires CSRF token and authentication + */ +void cls_webu_json::api_system_reboot() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for reboot from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + return; + } + + /* Check if power control is enabled via webcontrol_actions */ + bool power_enabled = false; + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "power") { + if (webu->wb_actions->params_array[indx].param_value == "on") { + power_enabled = true; + } + break; + } + } + + if (!power_enabled) { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Reboot request denied - power control disabled (from %s)", webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Power control is disabled\"}"; + return; + } + + /* Log the reboot request */ + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "System reboot requested by %s", webua->clientip.c_str()); + + /* Schedule reboot with 2-second delay to allow HTTP response to complete */ + std::thread([]() { + sleep(2); + /* Try reboot commands in sequence (like MotionEye) */ + if (system("sudo /sbin/reboot") != 0) { + if (system("sudo /sbin/shutdown -r now") != 0) { + if (system("sudo /usr/bin/systemctl reboot") != 0) { + system("sudo /sbin/init 6"); + } + } + } + }).detach(); + + webua->resp_page = "{\"success\":true,\"operation\":\"reboot\",\"message\":\"System will reboot in 2 seconds\"}"; +} + +/* + * React UI API: System shutdown + * POST /0/api/system/shutdown + * Requires CSRF token and authentication + */ +void cls_webu_json::api_system_shutdown() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for shutdown from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + return; + } + + /* Check if power control is enabled via webcontrol_actions */ + bool power_enabled = false; + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "power") { + if (webu->wb_actions->params_array[indx].param_value == "on") { + power_enabled = true; + } + break; + } + } + + if (!power_enabled) { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Shutdown request denied - power control disabled (from %s)", webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Power control is disabled\"}"; + return; + } + + /* Log the shutdown request */ + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "System shutdown requested by %s", webua->clientip.c_str()); + + /* Schedule shutdown with 2-second delay to allow HTTP response to complete */ + std::thread([]() { + sleep(2); + /* Try shutdown commands in sequence (like MotionEye) */ + if (system("sudo /sbin/poweroff") != 0) { + if (system("sudo /sbin/shutdown -h now") != 0) { + if (system("sudo /usr/bin/systemctl poweroff") != 0) { + system("sudo /sbin/init 0"); + } + } + } + }).detach(); + + webua->resp_page = "{\"success\":true,\"operation\":\"shutdown\",\"message\":\"System will shut down in 2 seconds\"}"; +} + +/* + * React UI API: Restart Motion service + * POST /0/api/system/service-restart + * Requires CSRF token and authentication + */ +void cls_webu_json::api_system_service_restart() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for service restart from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + return; + } + + /* Check if service control is enabled via webcontrol_actions */ + bool service_enabled = false; + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == "service") { + if (webu->wb_actions->params_array[indx].param_value == "on") { + service_enabled = true; + } + break; + } + } + + if (!service_enabled) { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Service restart request denied - service control disabled (from %s)", webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"Service control is disabled\"}"; + return; + } + + /* Log the restart request */ + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Motion service restart requested by %s", webua->clientip.c_str()); + + /* Schedule restart with 2-second delay to allow HTTP response to complete */ + std::thread([]() { + sleep(2); + system("sudo /usr/bin/systemctl restart motion"); + }).detach(); + + webua->resp_page = "{\"success\":true,\"operation\":\"service-restart\",\"message\":\"Motion service will restart in 2 seconds\"}"; +} + +/* + * React UI API: Cameras list + * Returns list of configured cameras + */ +void cls_webu_json::api_cameras() +{ + int indx_cam; + std::string strid; + cls_camera *cam; + + webua->resp_page = "{\"cameras\":["; + + for (indx_cam=0; indx_camcam_cnt; indx_cam++) { + cam = app->cam_list[indx_cam]; + strid = std::to_string(cam->cfg->device_id); + + if (indx_cam > 0) { + webua->resp_page += ","; + } + + webua->resp_page += "{"; + webua->resp_page += "\"id\":" + strid + ","; + + if (cam->cfg->device_name == "") { + webua->resp_page += "\"name\":\"camera " + strid + "\","; + } else { + webua->resp_page += "\"name\":\"" + escstr(cam->cfg->device_name) + "\","; + } + + webua->resp_page += "\"url\":\"" + webua->hostfull + "/" + strid + "/\""; + webua->resp_page += "}"; + } + + webua->resp_page += "]}"; + webua->resp_type = WEBUI_RESP_JSON; +} + +/* + * React UI API: Configuration + * Returns full Motion configuration including parameters and categories + * Includes CSRF token for React UI authentication + */ +void cls_webu_json::api_config() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Add CSRF token at the start of the response */ + webua->resp_page = "{\"csrf_token\":\"" + webu->csrf_token + "\""; + + /* Add version - config() normally starts with { so we skip it */ + webua->resp_page += ",\"version\" : \"" VERSION "\""; + + /* Add cameras list */ + webua->resp_page += ",\"cameras\" : "; + cameras_list(); + + /* Add configuration parameters */ + webua->resp_page += ",\"configuration\" : "; + parms_all(); + + /* Add categories */ + webua->resp_page += ",\"categories\" : "; + categories_list(); + + webua->resp_page += "}"; +} + +/* + * React UI API: Batch Configuration Update + * PATCH /0/api/config with JSON body containing multiple parameters + * Returns detailed results for each parameter change + */ +void cls_webu_json::api_config_patch() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for PATCH from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + /* Parse JSON body */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("JSON parse error: %s"), parser.getError().c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid JSON: " + + parser.getError() + "\"}"; + return; + } + + /* Get config for this camera/device */ + cls_config *cfg; + if (webua->cam != nullptr) { + cfg = webua->cam->cfg; + } else { + cfg = app->cfg; + } + + /* Start response */ + webua->resp_page = "{\"status\":\"ok\",\"applied\":["; + bool first_item = true; + int success_count = 0; + int error_count = 0; + + /* Process each parameter */ + pthread_mutex_lock(&app->mutex_post); + for (const auto& kv : parser.getAll()) { + std::string parm_name = kv.first; + std::string parm_val = parser.getString(parm_name); + std::string old_val; + int parm_index = -1; + bool applied = false; + bool hot_reload = false; + bool unchanged = false; + std::string error_msg; + + /* Auto-hash authentication passwords if not already hashed */ + if (parm_name == "webcontrol_authentication" || + parm_name == "webcontrol_user_authentication") { + + size_t colon_pos = parm_val.find(':'); + if (colon_pos != std::string::npos) { + std::string username = parm_val.substr(0, colon_pos); + std::string password = parm_val.substr(colon_pos + 1); + + /* If password is not already a bcrypt hash, hash it */ + if (!cls_webu_auth::is_bcrypt_hash(password)) { + std::string hashed = cls_webu_auth::hash_password(password); + if (!hashed.empty()) { + parm_val = username + ":" + hashed; + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Auto-hashed password for %s", parm_name.c_str()); + } else { + /* Hash failed - log error but allow plaintext */ + MOTION_LOG(WRN, TYPE_ALL, NO_ERRNO, + "Failed to hash password for %s - saving plaintext", parm_name.c_str()); + } + } + } + } + + /* SECURITY: Reject SQL parameter modifications */ + if (parm_name.substr(0, 4) == "sql_") { + error_msg = "SQL parameters cannot be modified via web interface (security restriction)"; + error_count++; + } + /* SECURITY: Allow initial authentication setup regardless of webcontrol_parms + * This enables first-run configuration without requiring webcontrol_parms 3 + * Exception only applies when BOTH auth parameters are empty (fresh install) + * Once any auth is configured, normal permission levels apply */ + else if ((parm_name == "webcontrol_authentication" || + parm_name == "webcontrol_user_authentication") && + cfg->webcontrol_authentication == "" && + cfg->webcontrol_user_authentication == "") { + /* Initial setup exception - find parameter without permission check */ + parm_index = 0; + while (config_parms[parm_index].parm_name != "") { + if (config_parms[parm_index].parm_name == parm_name) { + break; + } + parm_index++; + } + + if (config_parms[parm_index].parm_name == "") { + /* Parameter not found */ + parm_index = -1; + error_msg = "Unknown parameter"; + error_count++; + } else { + /* Parameter exists - get current value */ + cfg->edit_get(parm_name, old_val, config_parms[parm_index].parm_cat); + + /* Check if value actually changed */ + if (old_val == parm_val) { + unchanged = true; + hot_reload = config_parms[parm_index].hot_reload; + success_count++; + } else { + /* Authentication parameters require restart */ + cfg->edit_set(parm_name, parm_val); + applied = true; + hot_reload = false; + success_count++; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Initial setup: %s configured (restart required)", + parm_name.c_str()); + } + } + } + /* Check if parameter exists */ + else { + validate_hot_reload(parm_name, parm_index); + + if (parm_index < 0) { + /* Parameter doesn't exist */ + error_msg = "Unknown parameter"; + error_count++; + } else if (config_parms[parm_index].webui_level > app->cfg->webcontrol_parms) { + /* Permission level too low */ + error_msg = "Insufficient permissions (requires webcontrol_parms " + + std::to_string(config_parms[parm_index].webui_level) + ")"; + error_count++; + } else { + /* Parameter exists - get current value */ + cfg->edit_get(parm_name, old_val, config_parms[parm_index].parm_cat); + + /* Check if value actually changed */ + if (old_val == parm_val) { + unchanged = true; + hot_reload = config_parms[parm_index].hot_reload; + success_count++; + } else { + /* Check if hot-reloadable */ + if (config_parms[parm_index].hot_reload) { + /* Apply immediately */ + apply_hot_reload(parm_index, parm_val); + applied = true; + hot_reload = true; + success_count++; + } else { + /* Save to config - requires restart to take effect */ + cfg->edit_set(parm_name, parm_val); + + /* Also update source config for restart persistence */ + if (webua->cam != nullptr) { + webua->cam->conf_src->edit_set(parm_name, parm_val); + } else { + app->conf_src->edit_set(parm_name, parm_val); + } + + applied = true; + hot_reload = false; + success_count++; + } + } + } + } + + /* Add this parameter to response */ + if (!first_item) { + webua->resp_page += ","; + } + first_item = false; + + webua->resp_page += "{\"param\":\"" + parm_name + "\""; + webua->resp_page += ",\"old\":\"" + escstr(old_val) + "\""; + webua->resp_page += ",\"new\":\"" + escstr(parm_val) + "\""; + + if (unchanged) { + webua->resp_page += ",\"unchanged\":true"; + } else if (applied) { + webua->resp_page += ",\"hot_reload\":" + std::string(hot_reload ? "true" : "false"); + } + + if (!error_msg.empty()) { + webua->resp_page += ",\"error\":\"" + escstr(error_msg) + "\""; + } + + webua->resp_page += "}"; + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page += "]"; + webua->resp_page += ",\"summary\":{"; + webua->resp_page += "\"total\":" + std::to_string(success_count + error_count); + webua->resp_page += ",\"success\":" + std::to_string(success_count); + webua->resp_page += ",\"errors\":" + std::to_string(error_count); + webua->resp_page += "}}"; +} + +/* + * React UI API: Get mask information + * GET /{camId}/api/mask/{type} + * type = "motion" or "privacy" + */ +void cls_webu_json::api_mask_get() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + return; + } + + std::string type = webua->uri_cmd3; + if (type != "motion" && type != "privacy") { + webua->resp_page = "{\"error\":\"Invalid mask type. Use 'motion' or 'privacy'\"}"; + return; + } + + /* Get current mask path from config */ + std::string mask_path; + if (type == "motion") { + mask_path = webua->cam->cfg->mask_file; + } else { + mask_path = webua->cam->cfg->mask_privacy; + } + + webua->resp_page = "{"; + webua->resp_page += "\"type\":\"" + type + "\""; + + if (mask_path.empty()) { + webua->resp_page += ",\"exists\":false"; + webua->resp_page += ",\"path\":\"\""; + } else { + /* Check if file exists and get dimensions */ + FILE *f = myfopen(mask_path.c_str(), "rbe"); + if (f != nullptr) { + char line[256]; + int w = 0, h = 0; + + /* Skip magic number P5 */ + if (fgets(line, sizeof(line), f)) { + /* Skip comments */ + do { + if (!fgets(line, sizeof(line), f)) break; + } while (line[0] == '#'); + + /* Parse dimensions */ + sscanf(line, "%d %d", &w, &h); + } + myfclose(f); + + webua->resp_page += ",\"exists\":true"; + webua->resp_page += ",\"path\":\"" + escstr(mask_path) + "\""; + webua->resp_page += ",\"width\":" + std::to_string(w); + webua->resp_page += ",\"height\":" + std::to_string(h); + } else { + webua->resp_page += ",\"exists\":false"; + webua->resp_page += ",\"path\":\"" + escstr(mask_path) + "\""; + webua->resp_page += ",\"error\":\"File not accessible\""; + } + } + + webua->resp_page += "}"; +} + +/* + * React UI API: Save mask from polygon data + * POST /{camId}/api/mask/{type} + * Request body: {"polygons":[[{x,y},...]], "width":W, "height":H, "invert":bool} + */ +void cls_webu_json::api_mask_post() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + return; + } + + std::string type = webua->uri_cmd3; + if (type != "motion" && type != "privacy") { + webua->resp_page = "{\"error\":\"Invalid mask type. Use 'motion' or 'privacy'\"}"; + return; + } + + /* Validate CSRF (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + return; + } + + /* Parse JSON request body */ + std::string body = webua->raw_body; + + /* Extract dimensions - default to camera size */ + int img_width = webua->cam->imgs.width; + int img_height = webua->cam->imgs.height; + bool invert = false; + + /* Parse width/height from body if present */ + size_t pos = body.find("\"width\":"); + if (pos != std::string::npos) { + img_width = atoi(body.c_str() + pos + 8); + } + pos = body.find("\"height\":"); + if (pos != std::string::npos) { + img_height = atoi(body.c_str() + pos + 9); + } + pos = body.find("\"invert\":"); + if (pos != std::string::npos) { + invert = (body.substr(pos + 9, 4) == "true"); + } + + /* Validate dimensions match camera */ + if (img_width != webua->cam->imgs.width || img_height != webua->cam->imgs.height) { + MOTION_LOG(WRN, TYPE_ALL, NO_ERRNO, + "Mask dimensions %dx%d differ from camera %dx%d, will be resized on load", + img_width, img_height, webua->cam->imgs.width, webua->cam->imgs.height); + } + + /* Allocate bitmap */ + u_char default_val = invert ? 255 : 0; /* 255=detect, 0=mask */ + u_char fill_val = invert ? 0 : 255; + std::vector bitmap(img_width * img_height, default_val); + + /* Parse polygons array */ + /* Format: "polygons":[[[x,y],[x,y],...],[[x,y],...]] */ + pos = body.find("\"polygons\":"); + if (pos != std::string::npos) { + size_t start = body.find('[', pos); + if (start != std::string::npos) { + start++; /* Skip outer [ */ + + while (start < body.length() && body[start] != ']') { + /* Skip whitespace */ + while (start < body.length() && + (body[start] == ' ' || body[start] == '\n' || body[start] == ',')) { + start++; + } + + if (body[start] == '[') { + /* Parse one polygon */ + std::vector> polygon; + start++; /* Skip [ */ + + while (start < body.length() && body[start] != ']') { + /* Skip to { or [ */ + while (start < body.length() && + body[start] != '{' && body[start] != '[' && body[start] != ']') { + start++; + } + if (body[start] == ']') break; + + /* Parse point {x:N, y:N} or [x,y] */ + int x = 0, y = 0; + if (body[start] == '{') { + /* Object format */ + size_t xpos = body.find("\"x\":", start); + size_t ypos = body.find("\"y\":", start); + if (xpos != std::string::npos && ypos != std::string::npos) { + x = atoi(body.c_str() + xpos + 4); + y = atoi(body.c_str() + ypos + 4); + } + start = body.find('}', start) + 1; + } else if (body[start] == '[') { + /* Array format [x,y] */ + start++; + x = atoi(body.c_str() + start); + size_t comma = body.find(',', start); + if (comma != std::string::npos) { + y = atoi(body.c_str() + comma + 1); + } + start = body.find(']', start) + 1; + } + + polygon.push_back({x, y}); + } + start++; /* Skip ] */ + + /* Fill polygon */ + if (polygon.size() >= 3) { + fill_polygon(bitmap.data(), img_width, img_height, polygon, fill_val); + } + } else { + break; + } + } + } + } + + /* Generate mask path */ + std::string mask_path = build_mask_path(webua->cam, type); + + /* Write PGM file */ + FILE *f = myfopen(mask_path.c_str(), "wbe"); + if (f == nullptr) { + MOTION_LOG(ERR, TYPE_ALL, SHOW_ERRNO, + "Cannot write mask file: %s", mask_path.c_str()); + webua->resp_page = "{\"error\":\"Cannot write mask file\"}"; + return; + } + + /* Write PGM P5 header */ + fprintf(f, "P5\n"); + fprintf(f, "# Motion mask - type: %s\n", type.c_str()); + fprintf(f, "%d %d\n", img_width, img_height); + fprintf(f, "255\n"); + + /* Write bitmap data */ + if (fwrite(bitmap.data(), 1, bitmap.size(), f) != bitmap.size()) { + MOTION_LOG(ERR, TYPE_ALL, SHOW_ERRNO, + "Failed writing mask data to: %s", mask_path.c_str()); + myfclose(f); + webua->resp_page = "{\"error\":\"Failed writing mask data\"}"; + return; + } + + myfclose(f); + + /* Update config parameter */ + pthread_mutex_lock(&app->mutex_post); + if (type == "motion") { + webua->cam->cfg->mask_file = mask_path; + app->cfg->edit_set("mask_file", mask_path); + } else { + webua->cam->cfg->mask_privacy = mask_path; + app->cfg->edit_set("mask_privacy", mask_path); + } + pthread_mutex_unlock(&app->mutex_post); + + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Mask saved: %s (type=%s, %dx%d, polygons parsed)", + mask_path.c_str(), type.c_str(), img_width, img_height); + + webua->resp_page = "{"; + webua->resp_page += "\"success\":true"; + webua->resp_page += ",\"path\":\"" + escstr(mask_path) + "\""; + webua->resp_page += ",\"width\":" + std::to_string(img_width); + webua->resp_page += ",\"height\":" + std::to_string(img_height); + webua->resp_page += ",\"message\":\"Mask saved. Reload camera to apply.\""; + webua->resp_page += "}"; +} + +/* + * React UI API: Delete mask file + * DELETE /{camId}/api/mask/{type} + */ +void cls_webu_json::api_mask_delete() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (webua->cam == nullptr) { + webua->resp_page = "{\"error\":\"Camera not specified\"}"; + return; + } + + std::string type = webua->uri_cmd3; + if (type != "motion" && type != "privacy") { + webua->resp_page = "{\"error\":\"Invalid mask type. Use 'motion' or 'privacy'\"}"; + return; + } + + /* Validate CSRF (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + return; + } + + /* Get current mask path */ + std::string mask_path; + if (type == "motion") { + mask_path = webua->cam->cfg->mask_file; + } else { + mask_path = webua->cam->cfg->mask_privacy; + } + + bool file_deleted = false; + if (!mask_path.empty()) { + /* Security: Validate path doesn't contain traversal */ + if (mask_path.find("..") != std::string::npos) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + "Path traversal attempt blocked: %s", mask_path.c_str()); + webua->resp_page = "{\"error\":\"Invalid path\"}"; + return; + } + + /* Delete file */ + if (remove(mask_path.c_str()) == 0) { + file_deleted = true; + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "Deleted mask file: %s", mask_path.c_str()); + } else if (errno != ENOENT) { + MOTION_LOG(WRN, TYPE_ALL, SHOW_ERRNO, + "Failed to delete mask file: %s", mask_path.c_str()); + } + } + + /* Clear config parameter */ + pthread_mutex_lock(&app->mutex_post); + if (type == "motion") { + webua->cam->cfg->mask_file = ""; + app->cfg->edit_set("mask_file", ""); + } else { + webua->cam->cfg->mask_privacy = ""; + app->cfg->edit_set("mask_privacy", ""); + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{"; + webua->resp_page += "\"success\":true"; + webua->resp_page += ",\"deleted\":" + std::string(file_deleted ? "true" : "false"); + webua->resp_page += ",\"message\":\"Mask removed. Reload camera to apply.\""; + webua->resp_page += "}"; +} + +/* + * Configuration Profiles API: List all profiles for a camera + * GET /0/api/profiles?camera_id=X + */ +void cls_webu_json::api_profiles_list() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Get camera_id from query params (default to 0) */ + int camera_id = 0; + const char* cam_id_str = MHD_lookup_connection_value( + webua->connection, MHD_GET_ARGUMENT_KIND, "camera_id"); + if (cam_id_str != nullptr) { + camera_id = atoi(cam_id_str); + } + + /* Get profiles from database */ + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\",\"profiles\":[]}"; + return; + } + + std::vector profiles = app->profiles->list_profiles(camera_id); + + /* Build JSON response */ + webua->resp_page = "{\"status\":\"ok\",\"profiles\":["; + bool first = true; + for (const auto &prof : profiles) { + if (!first) { + webua->resp_page += ","; + } + first = false; + + webua->resp_page += "{"; + webua->resp_page += "\"profile_id\":" + std::to_string(prof.profile_id) + ","; + webua->resp_page += "\"camera_id\":" + std::to_string(prof.camera_id) + ","; + webua->resp_page += "\"name\":\"" + escstr(prof.name) + "\","; + webua->resp_page += "\"description\":\"" + escstr(prof.description) + "\","; + webua->resp_page += "\"is_default\":" + std::string(prof.is_default ? "true" : "false") + ","; + webua->resp_page += "\"created_at\":" + std::to_string((int64_t)prof.created_at) + ","; + webua->resp_page += "\"updated_at\":" + std::to_string((int64_t)prof.updated_at) + ","; + webua->resp_page += "\"param_count\":" + std::to_string(prof.param_count); + webua->resp_page += "}"; + } + webua->resp_page += "]}"; +} + +/* + * Configuration Profiles API: Get specific profile with parameters + * GET /0/api/profiles/{id} + */ +void cls_webu_json::api_profiles_get() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Parse profile_id from URI */ + int profile_id = atoi(webua->uri_cmd3.c_str()); + if (profile_id <= 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid profile ID\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Get profile info */ + ctx_profile_info info; + if (!app->profiles->get_profile_info(profile_id, info)) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile not found\"}"; + return; + } + + /* Load profile parameters */ + std::map params; + if (app->profiles->load_profile(profile_id, params) != 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Failed to load profile parameters\"}"; + return; + } + + /* Build JSON response with metadata + params */ + webua->resp_page = "{\"status\":\"ok\","; + webua->resp_page += "\"profile_id\":" + std::to_string(info.profile_id) + ","; + webua->resp_page += "\"camera_id\":" + std::to_string(info.camera_id) + ","; + webua->resp_page += "\"name\":\"" + escstr(info.name) + "\","; + webua->resp_page += "\"description\":\"" + escstr(info.description) + "\","; + webua->resp_page += "\"is_default\":" + std::string(info.is_default ? "true" : "false") + ","; + webua->resp_page += "\"created_at\":" + std::to_string((int64_t)info.created_at) + ","; + webua->resp_page += "\"updated_at\":" + std::to_string((int64_t)info.updated_at) + ","; + webua->resp_page += "\"params\":{"; + + bool first = true; + for (const auto &kv : params) { + if (!first) { + webua->resp_page += ","; + } + first = false; + webua->resp_page += "\"" + escstr(kv.first) + "\":\"" + escstr(kv.second) + "\""; + } + webua->resp_page += "}}"; +} + +/* + * Configuration Profiles API: Create new profile + * POST /0/api/profiles + * Body: {name, description?, camera_id, snapshot_current?, params?} + */ +void cls_webu_json::api_profiles_create() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for profile create from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Parse JSON body */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("JSON parse error: %s"), parser.getError().c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid JSON: " + + parser.getError() + "\"}"; + return; + } + + /* Extract required fields */ + std::string name = parser.getString("name"); + if (name.empty()) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile name is required\"}"; + return; + } + + std::string description = parser.getString("description", ""); + int camera_id = (int)parser.getNumber("camera_id", 0); + bool snapshot_current = parser.getBool("snapshot_current", false); + + /* Get parameters */ + std::map params; + + if (snapshot_current) { + /* Snapshot current configuration */ + cls_config *cfg; + if (webua->cam != nullptr) { + cfg = webua->cam->cfg; + } else { + cfg = app->cfg; + } + params = app->profiles->snapshot_config(cfg); + } else { + /* Use params from request body (TODO: parse nested params object) */ + /* For now, just create empty profile - params can be added via update */ + } + + /* Create profile */ + pthread_mutex_lock(&app->mutex_post); + int profile_id = app->profiles->create_profile(camera_id, name, description, params); + pthread_mutex_unlock(&app->mutex_post); + + if (profile_id < 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Failed to create profile\"}"; + return; + } + + webua->resp_page = "{\"status\":\"ok\",\"profile_id\":" + std::to_string(profile_id) + "}"; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + _("Profile created: id=%d, name='%s', camera=%d"), profile_id, name.c_str(), camera_id); +} + +/* + * Configuration Profiles API: Update profile parameters + * PATCH /0/api/profiles/{id} + * Body: {params: {...}} + */ +void cls_webu_json::api_profiles_update() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for profile update from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Parse profile_id from URI */ + int profile_id = atoi(webua->uri_cmd3.c_str()); + if (profile_id <= 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid profile ID\"}"; + return; + } + + /* Parse JSON body */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("JSON parse error: %s"), parser.getError().c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid JSON: " + + parser.getError() + "\"}"; + return; + } + + /* Extract params (for now, simple key-value pairs) */ + std::map params; + for (const auto &kv : parser.getAll()) { + params[kv.first] = parser.getString(kv.first); + } + + /* Update profile */ + pthread_mutex_lock(&app->mutex_post); + int retcd = app->profiles->update_profile(profile_id, params); + pthread_mutex_unlock(&app->mutex_post); + + if (retcd < 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Failed to update profile\"}"; + return; + } + + webua->resp_page = "{\"status\":\"ok\"}"; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + _("Profile updated: id=%d"), profile_id); +} + +/* + * Configuration Profiles API: Delete profile + * DELETE /0/api/profiles/{id} + */ +void cls_webu_json::api_profiles_delete() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for profile delete from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Parse profile_id from URI */ + int profile_id = atoi(webua->uri_cmd3.c_str()); + if (profile_id <= 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid profile ID\"}"; + return; + } + + /* Delete profile */ + pthread_mutex_lock(&app->mutex_post); + int retcd = app->profiles->delete_profile(profile_id); + pthread_mutex_unlock(&app->mutex_post); + + if (retcd < 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Failed to delete profile\"}"; + return; + } + + webua->resp_page = "{\"status\":\"ok\"}"; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + _("Profile deleted: id=%d"), profile_id); +} + +/* + * Configuration Profiles API: Apply profile to camera configuration + * POST /0/api/profiles/{id}/apply + */ +void cls_webu_json::api_profiles_apply() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for profile apply from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Parse profile_id from URI */ + int profile_id = atoi(webua->uri_cmd3.c_str()); + if (profile_id <= 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid profile ID\"}"; + return; + } + + /* Get config for this camera/device */ + cls_config *cfg; + if (webua->cam != nullptr) { + cfg = webua->cam->cfg; + } else { + cfg = app->cfg; + } + + /* Apply profile */ + pthread_mutex_lock(&app->mutex_post); + std::vector needs_restart = app->profiles->apply_profile(cfg, profile_id); + pthread_mutex_unlock(&app->mutex_post); + + /* Build response with restart requirements */ + webua->resp_page = "{\"status\":\"ok\",\"requires_restart\":["; + bool first = true; + for (const auto ¶m : needs_restart) { + if (!first) { + webua->resp_page += ","; + } + first = false; + webua->resp_page += "\"" + escstr(param) + "\""; + } + webua->resp_page += "]}"; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + _("Profile applied: id=%d, restart_required=%s"), + profile_id, needs_restart.empty() ? "no" : "yes"); +} + +/* + * Configuration Profiles API: Set profile as default for camera + * POST /0/api/profiles/{id}/default + */ +void cls_webu_json::api_profiles_set_default() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Validate CSRF token (supports both session and global tokens) */ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed for set default from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + return; + } + + if (!app->profiles || !app->profiles->enabled) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Profile system not available\"}"; + return; + } + + /* Parse profile_id from URI */ + int profile_id = atoi(webua->uri_cmd3.c_str()); + if (profile_id <= 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid profile ID\"}"; + return; + } + + /* Set as default */ + pthread_mutex_lock(&app->mutex_post); + int retcd = app->profiles->set_default_profile(profile_id); + pthread_mutex_unlock(&app->mutex_post); + + if (retcd < 0) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Failed to set default profile\"}"; + return; + } + + webua->resp_page = "{\"status\":\"ok\"}"; + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + _("Default profile set: id=%d"), profile_id); +} + +/* + * CSRF validation helper for POST endpoints + * Gets token from X-CSRF-Token header and validates against session or global token + * Returns true if valid, false otherwise (also sets error response) + */ +bool cls_webu_json::validate_csrf() +{ + const char* csrf_token = MHD_lookup_connection_value( + webua->connection, MHD_HEADER_KIND, "X-CSRF-Token"); + if (!webu->csrf_validate_request(csrf_token ? std::string(csrf_token) : "", webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"error\":\"CSRF validation failed\"}"; + webua->resp_code = 403; + return false; + } + return true; +} + +/* + * Check if an action is enabled in webcontrol_actions + * Returns true if action is enabled (or not explicitly disabled), false if disabled + */ +bool cls_webu_json::check_action_permission(const std::string &action_name) +{ + for (int indx = 0; indx < webu->wb_actions->params_cnt; indx++) { + if (webu->wb_actions->params_array[indx].param_name == action_name) { + if (webu->wb_actions->params_array[indx].param_value == "off") { + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, + "%s action disabled", action_name.c_str()); + webua->resp_page = "{\"error\":\"" + action_name + " action is disabled\"}"; + return false; + } + break; + } + } + return true; +} + +/* + * Camera action API: Write configuration to file + * POST /0/api/config/write + * Saves current configuration parameters to file + */ +void cls_webu_json::api_config_write() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("config_write")) { + return; + } + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Config write requested by %s", webua->clientip.c_str()); + + pthread_mutex_lock(&app->mutex_post); + app->conf_src->parms_write(); + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: Restart camera(s) + * POST /{camId}/api/camera/restart + * If camId=0, restart all cameras; otherwise restart specific camera + */ +void cls_webu_json::api_camera_restart() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("restart")) { + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Restarting all cameras")); + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->handler_stop = false; + app->cam_list[indx]->restart = true; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Restarting camera %d"), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->handler_stop = false; + app->cam_list[webua->camindx]->restart = true; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: Take snapshot + * POST /{camId}/api/camera/snapshot + * If camId=0, snapshot all cameras; otherwise snapshot specific camera + */ +void cls_webu_json::api_camera_snapshot() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("snapshot")) { + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Snapshot requested for all cameras")); + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->action_snapshot = true; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Snapshot requested for camera %d"), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->action_snapshot = true; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: Pause/unpause detection + * POST /{camId}/api/camera/pause + * Body: {"action": "on"|"off"|"schedule"} + * If camId=0, applies to all cameras; otherwise specific camera + */ +void cls_webu_json::api_camera_pause() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("pause")) { + return; + } + + /* Parse JSON body for action */ + std::string action = "on"; /* Default to pause on */ + if (!webua->raw_body.empty()) { + JsonParser parser; + if (parser.parse(webua->raw_body)) { + std::string parsed_action = parser.getString("action"); + if (!parsed_action.empty()) { + action = parsed_action; + } + } + } + + /* Validate action value */ + if (action != "on" && action != "off" && action != "schedule") { + webua->resp_page = "{\"error\":\"Invalid action. Use 'on', 'off', or 'schedule'\"}"; + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Pause %s requested for all cameras"), action.c_str()); + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->user_pause = action; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Pause %s requested for camera %d"), action.c_str(), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->user_pause = action; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\",\"action\":\"" + action + "\"}"; +} + +/* + * Camera action API: Stop camera(s) + * POST /{camId}/api/camera/stop + * If camId=0, stop all cameras; otherwise stop specific camera + */ +void cls_webu_json::api_camera_stop() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("stop")) { + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + for (int indx = 0; indx < app->cam_cnt; indx++) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Stopping camera %d"), + app->cam_list[indx]->cfg->device_id); + app->cam_list[indx]->restart = false; + app->cam_list[indx]->event_stop = true; + app->cam_list[indx]->event_user = false; + app->cam_list[indx]->handler_stop = true; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Stopping camera %d"), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->restart = false; + app->cam_list[webua->camindx]->event_stop = true; + app->cam_list[webua->camindx]->event_user = false; + app->cam_list[webua->camindx]->handler_stop = true; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: Trigger event start + * POST /{camId}/api/camera/event/start + * If camId=0, trigger for all cameras; otherwise specific camera + */ +void cls_webu_json::api_camera_event_start() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("event")) { + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Event start triggered for all cameras")); + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->event_user = true; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Event start triggered for camera %d"), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->event_user = true; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: Trigger event end + * POST /{camId}/api/camera/event/end + * If camId=0, trigger for all cameras; otherwise specific camera + */ +void cls_webu_json::api_camera_event_end() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("event")) { + return; + } + + pthread_mutex_lock(&app->mutex_post); + if (webua->device_id == 0) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Event end triggered for all cameras")); + for (int indx = 0; indx < app->cam_cnt; indx++) { + app->cam_list[indx]->event_stop = true; + } + } else { + if (webua->camindx >= 0 && webua->camindx < app->cam_cnt) { + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("Event end triggered for camera %d"), + app->cam_list[webua->camindx]->cfg->device_id); + app->cam_list[webua->camindx]->event_stop = true; + } else { + pthread_mutex_unlock(&app->mutex_post); + webua->resp_page = "{\"error\":\"Invalid camera ID\"}"; + return; + } + } + pthread_mutex_unlock(&app->mutex_post); + + webua->resp_page = "{\"status\":\"ok\"}"; +} + +/* + * Camera action API: PTZ control + * POST /{camId}/api/camera/ptz + * Body: {"action": "pan_left"|"pan_right"|"tilt_up"|"tilt_down"|"zoom_in"|"zoom_out"} + * Requires specific camera (camId != 0) + */ +void cls_webu_json::api_camera_ptz() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (!check_action_permission("ptz")) { + return; + } + + /* PTZ requires a specific camera */ + if (webua->camindx < 0 || webua->camindx >= app->cam_cnt) { + webua->resp_page = "{\"error\":\"PTZ requires a specific camera ID\"}"; + return; + } + + /* Parse JSON body for action */ + if (webua->raw_body.empty()) { + webua->resp_page = "{\"error\":\"Missing request body with action\"}"; + return; + } + + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + webua->resp_page = "{\"error\":\"Invalid JSON: " + parser.getError() + "\"}"; + return; + } + + std::string action = parser.getString("action"); + if (action.empty()) { + webua->resp_page = "{\"error\":\"Missing 'action' field\"}"; + return; + } + + cls_camera *cam = app->cam_list[webua->camindx]; + std::string ptz_cmd; + + /* Map action to PTZ command */ + if (action == "pan_left" && !cam->cfg->ptz_pan_left.empty()) { + ptz_cmd = cam->cfg->ptz_pan_left; + } else if (action == "pan_right" && !cam->cfg->ptz_pan_right.empty()) { + ptz_cmd = cam->cfg->ptz_pan_right; + } else if (action == "tilt_up" && !cam->cfg->ptz_tilt_up.empty()) { + ptz_cmd = cam->cfg->ptz_tilt_up; + } else if (action == "tilt_down" && !cam->cfg->ptz_tilt_down.empty()) { + ptz_cmd = cam->cfg->ptz_tilt_down; + } else if (action == "zoom_in" && !cam->cfg->ptz_zoom_in.empty()) { + ptz_cmd = cam->cfg->ptz_zoom_in; + } else if (action == "zoom_out" && !cam->cfg->ptz_zoom_out.empty()) { + ptz_cmd = cam->cfg->ptz_zoom_out; + } else { + webua->resp_page = "{\"error\":\"Invalid or unconfigured PTZ action: " + action + "\"}"; + return; + } + + pthread_mutex_lock(&app->mutex_post); + cam->frame_skip = cam->cfg->ptz_wait; + util_exec_command(cam, ptz_cmd); + pthread_mutex_unlock(&app->mutex_post); + + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, + _("PTZ %s executed for camera %d"), action.c_str(), cam->cfg->device_id); + + webua->resp_page = "{\"status\":\"ok\",\"action\":\"" + action + "\"}"; +} + +/* + * Camera detection API: Get platform information + * GET /0/api/cameras/platform + * Returns platform details (Pi model, capabilities) + */ +void cls_webu_json::api_cameras_platform() +{ + webua->resp_type = WEBUI_RESP_JSON; + + auto platform_info = app->cam_detect->get_platform_info(); + + webua->resp_page = "{"; + webua->resp_page += "\"is_raspberry_pi\":"; + webua->resp_page += platform_info.is_raspberry_pi ? "true" : "false"; + webua->resp_page += ",\"pi_model\":\"" + escstr(platform_info.pi_model) + "\""; + webua->resp_page += ",\"has_libcamera\":"; + webua->resp_page += platform_info.has_libcamera ? "true" : "false"; + webua->resp_page += ",\"has_v4l2\":"; + webua->resp_page += platform_info.has_v4l2 ? "true" : "false"; + webua->resp_page += "}"; +} + +/* + * Camera detection API: Get detected cameras + * GET /0/api/cameras/detected + * Returns list of unconfigured cameras + */ +void cls_webu_json::api_cameras_detected() +{ + webua->resp_type = WEBUI_RESP_JSON; + + auto cameras = app->cam_detect->detect_cameras(); + + webua->resp_page = "{\"cameras\":["; + + bool first = true; + for (const auto &cam : cameras) { + /* Only return unconfigured cameras */ + if (cam.already_configured) { + continue; + } + + if (!first) { + webua->resp_page += ","; + } + first = false; + + webua->resp_page += "{"; + webua->resp_page += "\"type\":\""; + if (cam.type == CAM_DETECT_LIBCAM) { + webua->resp_page += "libcam"; + } else if (cam.type == CAM_DETECT_V4L2) { + webua->resp_page += "v4l2"; + } else { + webua->resp_page += "netcam"; + } + webua->resp_page += "\""; + webua->resp_page += ",\"device_id\":\"" + escstr(cam.device_id) + "\""; + webua->resp_page += ",\"device_path\":\"" + escstr(cam.device_path) + "\""; + webua->resp_page += ",\"device_name\":\"" + escstr(cam.device_name) + "\""; + webua->resp_page += ",\"sensor_model\":\"" + escstr(cam.sensor_model) + "\""; + webua->resp_page += ",\"default_width\":" + std::to_string(cam.default_width); + webua->resp_page += ",\"default_height\":" + std::to_string(cam.default_height); + webua->resp_page += ",\"default_fps\":" + std::to_string(cam.default_fps); + + /* Add available resolutions */ + webua->resp_page += ",\"resolutions\":["; + for (size_t i = 0; i < cam.resolutions.size(); i++) { + if (i > 0) { + webua->resp_page += ","; + } + webua->resp_page += "[" + std::to_string(cam.resolutions[i].first); + webua->resp_page += "," + std::to_string(cam.resolutions[i].second) + "]"; + } + webua->resp_page += "]"; + + webua->resp_page += "}"; + } + + webua->resp_page += "]}"; +} + +/* + * Camera detection API: Add camera from detection + * POST /0/api/cameras + * Adds a detected camera to the configuration + */ +void cls_webu_json::api_cameras_add() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + /* Parse JSON body */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("JSON parse error: %s"), parser.getError().c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid JSON\"}"; + return; + } + + /* Get camera details from JSON */ + std::string type = parser.getString("type", ""); + std::string device_id = parser.getString("device_id", ""); + std::string device_path = parser.getString("device_path", ""); + std::string device_name = parser.getString("device_name", ""); + std::string sensor_model = parser.getString("sensor_model", ""); + int width = static_cast(parser.getNumber("width", 0)); + int height = static_cast(parser.getNumber("height", 0)); + int fps = static_cast(parser.getNumber("fps", 0)); + + if (type.empty() || device_path.empty()) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Missing required fields\"}"; + return; + } + + /* Create detected camera struct */ + ctx_detected_cam detected; + if (type == "libcam") { + detected.type = CAM_DETECT_LIBCAM; + } else if (type == "v4l2") { + detected.type = CAM_DETECT_V4L2; + } else if (type == "netcam") { + detected.type = CAM_DETECT_NETCAM; + } else { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid camera type\"}"; + return; + } + + detected.device_id = device_id; + detected.device_path = device_path; + detected.device_name = device_name; + detected.sensor_model = sensor_model; + detected.default_width = width; + detected.default_height = height; + detected.default_fps = fps; + + /* Add camera to configuration */ + pthread_mutex_lock(&app->mutex_post); + app->conf_src->camera_add_from_detection(detected); + pthread_mutex_unlock(&app->mutex_post); + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Camera added via API: %s [%s]", device_name.c_str(), device_path.c_str()); + + webua->resp_page = "{\"status\":\"ok\",\"message\":\"Camera added successfully\"}"; +} + +/* + * Camera detection API: Delete camera + * DELETE /{camId}/api/cameras + * Removes a camera from the configuration + */ +void cls_webu_json::api_cameras_delete() +{ + webua->resp_type = WEBUI_RESP_JSON; + + if (!validate_csrf()) { + return; + } + + if (webua->camindx < 0 || webua->camindx >= app->cam_cnt) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid camera ID\"}"; + return; + } + + pthread_mutex_lock(&app->mutex_post); + app->cam_delete = webua->device_id; + pthread_mutex_unlock(&app->mutex_post); + + MOTION_LOG(NTC, TYPE_ALL, NO_ERRNO, + "Camera delete requested via API: camera %d", webua->device_id); + + webua->resp_page = "{\"status\":\"ok\",\"message\":\"Camera will be removed\"}"; +} + +/* + * Camera detection API: Test network camera connection + * POST /0/api/cameras/test + * Tests if a network camera URL is accessible + */ +void cls_webu_json::api_cameras_test_netcam() +{ + webua->resp_type = WEBUI_RESP_JSON; + + /* Parse JSON body */ + JsonParser parser; + if (!parser.parse(webua->raw_body)) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Invalid JSON\"}"; + return; + } + + std::string url = parser.getString("url", ""); + std::string user = parser.getString("user", ""); + std::string pass = parser.getString("pass", ""); + int timeout = static_cast(parser.getNumber("timeout", 5)); + + if (url.empty()) { + webua->resp_page = "{\"status\":\"error\",\"message\":\"URL is required\"}"; + return; + } + + bool success = app->cam_detect->test_netcam(url, user, pass, timeout); + + if (success) { + webua->resp_page = "{\"status\":\"ok\",\"message\":\"Connection successful\"}"; + } else { + webua->resp_page = "{\"status\":\"error\",\"message\":\"Connection failed\"}"; + } +} + void cls_webu_json::main() { pthread_mutex_lock(&app->mutex_post); diff --git a/src/webu_json.hpp b/src/webu_json.hpp index 4092e365..9a6773b7 100644 --- a/src/webu_json.hpp +++ b/src/webu_json.hpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_json.hpp - JSON REST API Interface + * + * Header file defining the JSON REST API class for configuration + * management, camera control, status queries, and profile operations + * between the React frontend and Motion backend. + * + */ #ifndef _INCLUDE_WEBU_JSON_HPP_ #define _INCLUDE_WEBU_JSON_HPP_ @@ -23,6 +32,57 @@ cls_webu_json(cls_webu_ans *p_webua); ~cls_webu_json(); void main(); + + /* React UI API endpoints */ + void api_auth_me(); + void api_auth_login(); + void api_auth_logout(); + void api_auth_status(); + void api_media_pictures(); + void api_media_movies(); + void api_media_dates(); + void api_media_folders(); /* GET /{camId}/api/media/folders */ + void api_delete_picture(); + void api_delete_movie(); + void api_delete_folder_files(); /* DELETE /{camId}/api/media/folders/files */ + void api_system_temperature(); + void api_system_status(); + void api_system_reboot(); /* POST /0/api/system/reboot */ + void api_system_shutdown(); /* POST /0/api/system/shutdown */ + void api_system_service_restart(); /* POST /0/api/system/service-restart */ + void api_cameras(); + void api_config(); + void api_config_patch(); /* Batch config update via PATCH */ + void api_mask_get(); /* GET /{camId}/api/mask/{type} */ + void api_mask_post(); /* POST /{camId}/api/mask/{type} */ + void api_mask_delete(); /* DELETE /{camId}/api/mask/{type} */ + + /* Configuration Profile API endpoints */ + void api_profiles_list(); /* GET /0/api/profiles?camera_id=X */ + void api_profiles_get(); /* GET /0/api/profiles/{id} */ + void api_profiles_create(); /* POST /0/api/profiles */ + void api_profiles_update(); /* PATCH /0/api/profiles/{id} */ + void api_profiles_delete(); /* DELETE /0/api/profiles/{id} */ + void api_profiles_apply(); /* POST /0/api/profiles/{id}/apply */ + void api_profiles_set_default(); /* POST /0/api/profiles/{id}/default */ + + /* Camera detection API endpoints */ + void api_cameras_platform(); /* GET /0/api/cameras/platform */ + void api_cameras_detected(); /* GET /0/api/cameras/detected */ + void api_cameras_add(); /* POST /0/api/cameras */ + void api_cameras_delete(); /* DELETE /{camId}/api/cameras */ + void api_cameras_test_netcam(); /* POST /0/api/cameras/test */ + + /* Camera action API endpoints (JSON replacements for legacy POST) */ + void api_config_write(); /* POST /0/api/config/write */ + void api_camera_restart(); /* POST /{camId}/api/camera/restart */ + void api_camera_snapshot(); /* POST /{camId}/api/camera/snapshot */ + void api_camera_pause(); /* POST /{camId}/api/camera/pause */ + void api_camera_stop(); /* POST /{camId}/api/camera/stop */ + void api_camera_event_start(); /* POST /{camId}/api/camera/event/start */ + void api_camera_event_end(); /* POST /{camId}/api/camera/event/end */ + void api_camera_ptz(); /* POST /{camId}/api/camera/ptz */ + private: cls_motapp *app; cls_webu *webu; @@ -39,6 +99,21 @@ void status(); void loghistory(); std::string escstr(std::string invar); + void parms_item_detail(cls_config *conf, std::string pNm); + + /* Hot reload helpers */ + bool validate_hot_reload(const std::string &parm_name, int &parm_index); + void apply_hot_reload_to_camera(cls_camera *cam, + const std::string &parm_name, const std::string &parm_val); + void apply_hot_reload(int parm_index, const std::string &parm_val); + void build_response(bool success, const std::string &parm_name, + const std::string &old_val, const std::string &new_val, + bool hot_reload); + + /* CSRF validation helper for POST endpoints */ + bool validate_csrf(); + /* webcontrol_actions permission check helper */ + bool check_action_permission(const std::string &action_name); }; #endif /* _INCLUDE_WEBU_JSON_HPP_ */ diff --git a/src/webu_mpegts.cpp b/src/webu_mpegts.cpp index 1c685092..c2b55859 100644 --- a/src/webu_mpegts.cpp +++ b/src/webu_mpegts.cpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_mpegts.cpp - MPEG-TS Streaming Implementation + * + * This module provides H.264 video streaming in MPEG Transport Stream + * format over HTTP, delivering lower-latency video streams compared to + * MJPEG for compatible browsers and applications. + * + */ #include "motion.hpp" #include "util.hpp" @@ -30,7 +39,13 @@ /****** Callback functions for MHD ****************************************/ -static int webu_mpegts_avio_buf(void *opaque, myuint *buf, int buf_size) +#ifdef FF_API_AVIO_WRITE_NONCONST +/* FFmpeg 6.x and earlier - write_packet callback uses non-const uint8_t* */ +static int webu_mpegts_avio_buf(void *opaque, uint8_t *buf, int buf_size) +#else +/* FFmpeg 7.0+ - write_packet callback uses const uint8_t* */ +static int webu_mpegts_avio_buf(void *opaque, const uint8_t *buf, int buf_size) +#endif { cls_webu_mpegts *webu_mpegts; webu_mpegts =(cls_webu_mpegts *)opaque; @@ -84,7 +99,7 @@ int cls_webu_mpegts::pic_send(unsigned char *img) retcd = avcodec_send_frame(ctx_codec, picture); if (retcd < 0 ) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO , _("Error sending frame for encoding:%s"), errstr); av_frame_free(&picture); picture = NULL; @@ -111,7 +126,7 @@ int cls_webu_mpegts::pic_get() } if (retcd < 0 ) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("Error receiving encoded packet video:%s"), errstr); //Packet is freed upon failure of encoding return -1; @@ -122,7 +137,7 @@ int cls_webu_mpegts::pic_get() retcd = av_interleaved_write_frame(fmtctx, pkt); if (retcd < 0 ) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("Error while writing video frame. %s"), errstr); return -1; } @@ -221,13 +236,19 @@ int cls_webu_mpegts::getimg() return 0; } -int cls_webu_mpegts::avio_buf(myuint *buf, int buf_size) +#ifdef FF_API_AVIO_WRITE_NONCONST +/* FFmpeg 6.x and earlier - write_packet callback uses non-const uint8_t* */ +int cls_webu_mpegts::avio_buf(uint8_t *buf, int buf_size) +#else +/* FFmpeg 7.0+ - write_packet callback uses const uint8_t* */ +int cls_webu_mpegts::avio_buf(const uint8_t *buf, int buf_size) +#endif { if (webus->resp_size < (size_t)buf_size + webus->resp_used) { webus->resp_size = (size_t)buf_size + webus->resp_used; webus->resp_image = (unsigned char*)realloc( webus->resp_image, webus->resp_size); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("resp_image reallocated %d %d %d") ,webus->resp_size ,webus->resp_used @@ -355,7 +376,7 @@ int cls_webu_mpegts::open_mpegts() retcd = avcodec_open2(ctx_codec, codec, &opts); if (retcd < 0) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("Failed to open codec context for %dx%d transport stream: %s") , img_w, img_h, errstr); av_dict_free(&opts); @@ -365,7 +386,7 @@ int cls_webu_mpegts::open_mpegts() retcd = avcodec_parameters_from_context(strm->codecpar, ctx_codec); if (retcd < 0) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("Failed to copy decoder parameters!: %s"), errstr); av_dict_free(&opts); return -1; @@ -387,7 +408,7 @@ int cls_webu_mpegts::open_mpegts() retcd = avformat_write_header(fmtctx, &opts); if (retcd < 0) { av_strerror(retcd, errstr, sizeof(errstr)); - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO ,_("Failed to write header!: %s"), errstr); av_dict_free(&opts); return -1; @@ -414,7 +435,7 @@ mhdrslt cls_webu_mpegts::main() } if (open_mpegts() < 0 ) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Unable to open mpegts")); + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Unable to open mpegts")); return MHD_NO; } @@ -423,7 +444,7 @@ mhdrslt cls_webu_mpegts::main() response = MHD_create_response_from_callback (MHD_SIZE_UNKNOWN, 4096 ,&webu_mpegts_response, this, NULL); if (!response) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); return MHD_NO; } diff --git a/src/webu_mpegts.hpp b/src/webu_mpegts.hpp index 49c9556d..9a444895 100644 --- a/src/webu_mpegts.hpp +++ b/src/webu_mpegts.hpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_mpegts.hpp - MPEG-TS Streaming Interface + * + * Header file defining the MPEG Transport Stream class for H.264 video + * streaming over HTTP, providing lower-latency streams compared to MJPEG + * for compatible browsers and applications. + * + */ #ifndef _INCLUDE_WEBU_MPEGTS_HPP_ #define _INCLUDE_WEBU_MPEGTS_HPP_ @@ -23,7 +32,11 @@ public: cls_webu_mpegts(cls_webu_ans *p_webua, cls_webu_stream *p_webus); ~cls_webu_mpegts(); - int avio_buf(myuint *buf, int buf_size); + #ifdef FF_API_AVIO_WRITE_NONCONST + int avio_buf(uint8_t *buf, int buf_size); + #else + int avio_buf(const uint8_t *buf, int buf_size); + #endif ssize_t response(char *buf, size_t max); mhdrslt main(); diff --git a/src/webu_post.cpp b/src/webu_post.cpp index 1c5005ba..fd7bd682 100644 --- a/src/webu_post.cpp +++ b/src/webu_post.cpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_post.cpp - HTTP POST Request Handler + * + * This module processes HTTP POST requests for configuration updates, + * camera commands, and profile operations, parsing JSON request bodies + * and coordinating with webu_json for API endpoint handling. + * + */ #include "motion.hpp" #include "util.hpp" @@ -26,7 +35,7 @@ #include "logger.hpp" #include "webu.hpp" #include "webu_ans.hpp" -#include "webu_html.hpp" +#include "webu_json.hpp" #include "webu_post.hpp" /**************Callback functions for MHD **********************/ @@ -56,7 +65,7 @@ void cls_webu_post::cam_add() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "camera_add") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Camera add action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Camera add action disabled"); return; } else { break; @@ -64,7 +73,7 @@ void cls_webu_post::cam_add() } } - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Adding camera."); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Adding camera."); maxcnt = 100; @@ -77,11 +86,11 @@ void cls_webu_post::cam_add() if (indx == maxcnt) { app->cam_add = false; - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO, "Error adding camera. Timed out"); + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO, "Error adding camera. Timed out"); return; } - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "New camera added."); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "New camera added."); } @@ -93,7 +102,7 @@ void cls_webu_post::cam_delete() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "camera_delete") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Camera delete action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Camera delete action disabled"); return; } else { break; @@ -101,7 +110,7 @@ void cls_webu_post::cam_delete() } } - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Deleting camera."); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Deleting camera."); app->cam_delete = webua->camindx; @@ -112,7 +121,7 @@ void cls_webu_post::cam_delete() indx++; } if (indx == maxcnt) { - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO, "Error stopping camera. Timed out shutting down"); + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO, "Error stopping camera. Timed out shutting down"); app->cam_delete = -1; return; } @@ -137,19 +146,19 @@ void cls_webu_post::parse_cmd() webua->device_id = atoi(post_info[indx].key_val); } - MOTPLS_LOG(DBG, TYPE_STREAM, NO_ERRNO ,"key: %s value: %s " + MOTION_LOG(DBG, TYPE_STREAM, NO_ERRNO ,"key: %s value: %s " , post_info[indx].key_nm , post_info[indx].key_val ); } if (post_cmd == "") { - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO , "Invalid post request. No command"); return; } if (webua->device_id == -1) { - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO , "Invalid post request. No camera id provided"); return; } @@ -162,7 +171,7 @@ void cls_webu_post::parse_cmd() } } if (webua->camindx == -1) { - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO , "Invalid request. Device id %d not found" , webua->device_id); webua->device_id = -1; @@ -179,7 +188,7 @@ void cls_webu_post::action_eventend() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "event") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Event end action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Event end action disabled"); return; } else { break; @@ -205,7 +214,7 @@ void cls_webu_post::action_eventstart() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "event") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Event start action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Event start action disabled"); return; } else { break; @@ -232,7 +241,7 @@ void cls_webu_post::action_snapshot() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "snapshot") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Snapshot action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Snapshot action disabled"); return; } else { break; @@ -258,7 +267,7 @@ void cls_webu_post::action_pause_on() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "pause") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); return; } else { break; @@ -283,7 +292,7 @@ void cls_webu_post::action_pause_off() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "pause") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); return; } else { break; @@ -308,7 +317,7 @@ void cls_webu_post::action_pause_schedule() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "pause") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Pause action disabled"); return; } else { break; @@ -334,7 +343,7 @@ void cls_webu_post::action_restart() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "restart") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Restart action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Restart action disabled"); return; } else { break; @@ -343,13 +352,13 @@ void cls_webu_post::action_restart() } if (webua->device_id == 0) { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Restarting all cameras")); + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO, _("Restarting all cameras")); for (indx=0; indxcam_cnt; indx++) { app->cam_list[indx]->handler_stop = false; app->cam_list[indx]->restart = true; } } else { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Restarting camera %d") , app->cam_list[webua->camindx]->cfg->device_id); app->cam_list[webua->camindx]->handler_stop = false; @@ -365,7 +374,7 @@ void cls_webu_post::action_stop() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "stop") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Stop action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Stop action disabled"); return; } else { break; @@ -375,7 +384,7 @@ void cls_webu_post::action_stop() if (webua->device_id == 0) { for (indx=0; indxcam_cnt; indx++) { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Stopping cam %d") , app->cam_list[indx]->cfg->device_id); app->cam_list[indx]->restart = false; @@ -384,7 +393,7 @@ void cls_webu_post::action_stop() app->cam_list[indx]->handler_stop = true; } } else { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Stopping cam %d") , app->cam_list[webua->camindx]->cfg->device_id); app->cam_list[webua->camindx]->restart = false; @@ -405,7 +414,7 @@ void cls_webu_post::action_user() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "action_user") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "User action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "User action disabled"); return; } else { break; @@ -424,14 +433,14 @@ void cls_webu_post::action_user() } for (indx2 = 0; indx2<(int)tmp.length(); indx2++) { if (isalnum(tmp.at((uint)indx2)) == false) { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Invalid character included in action user \"%c\"") , tmp.at((uint)indx2)); return; } } snprintf(cam->action_user, 40, "%s", tmp.c_str()); - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Executing user action on cam %d") , cam->cfg->device_id); util_exec_command(cam, cam->cfg->on_action_user.c_str(), NULL); @@ -446,7 +455,7 @@ void cls_webu_post::action_user() } for (indx2 = 0; indx2<(int)tmp.length(); indx2++) { if (isalnum(tmp.at((uint)indx2)) == false) { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Invalid character included in action user \"%c\"") , tmp.at((uint)indx2)); return; @@ -454,7 +463,7 @@ void cls_webu_post::action_user() } snprintf(cam->action_user, 40, "%s", tmp.c_str()); - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("Executing user action on cam %d") , cam->cfg->device_id); util_exec_command(cam, cam->cfg->on_action_user.c_str(), NULL); @@ -470,7 +479,7 @@ void cls_webu_post::write_config() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "config_write") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Config write action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Config write action disabled"); return; } else { break; @@ -540,7 +549,7 @@ void cls_webu_post::config_set(int indx_parm, std::string parm_vl) (parm_ct == PARM_CAT_15)) { return; } - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Config edit set. %s:%s" + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Config edit set. %s:%s" ,parm_nm.c_str(), parm_vl.c_str()); app->cam_list[webua->camindx]->conf_src->edit_set( parm_nm, parm_vl); @@ -607,7 +616,7 @@ void cls_webu_post::config() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "config") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "Config save action disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "Config save action disabled"); return; } else { break; @@ -622,14 +631,6 @@ void cls_webu_post::config() mystrne(post_info[indx].key_nm, "camid")) { tmpname = post_info[indx].key_nm; - indx2=0; - while (config_parms_depr[indx2].parm_name != "") { - if (config_parms_depr[indx2].parm_name == tmpname) { - tmpname = config_parms_depr[indx2].newname; - break; - } - indx2++; - } /* Ignore any requests for parms above webcontrol_parms level. */ indx2=0; @@ -655,28 +656,28 @@ void cls_webu_post::config() if (restart_list[indx].restart == true) { if (restart_list[indx].comp_type == "log") { motlog->restart = true; - MOTPLS_LOG(DBG, TYPE_ALL, NO_ERRNO, + MOTION_LOG(DBG, TYPE_ALL, NO_ERRNO, "Restart request for log"); } else if (restart_list[indx].comp_type == "webu") { app->webu->restart = true; - MOTPLS_LOG(DBG, TYPE_ALL, NO_ERRNO, + MOTION_LOG(DBG, TYPE_ALL, NO_ERRNO, "Restart request for webcontrol"); } else if (restart_list[indx].comp_type == "dbse") { app->dbse->restart = true; - MOTPLS_LOG(DBG, TYPE_ALL, NO_ERRNO, + MOTION_LOG(DBG, TYPE_ALL, NO_ERRNO, "Restart request for database"); } else if (restart_list[indx].comp_type == "cam") { app->cam_list[restart_list[indx].comp_indx]->restart = true; - MOTPLS_LOG(DBG, TYPE_ALL, NO_ERRNO, + MOTION_LOG(DBG, TYPE_ALL, NO_ERRNO, "Restart request for camera %d" , app->cam_list[restart_list[indx].comp_indx]->cfg->device_id); } else if (restart_list[indx].comp_type == "snd") { app->snd_list[restart_list[indx].comp_indx]->restart = true; - MOTPLS_LOG(DBG, TYPE_ALL, NO_ERRNO, + MOTION_LOG(DBG, TYPE_ALL, NO_ERRNO, "Restart request for sound %d" , app->cam_list[restart_list[indx].comp_indx]->cfg->device_id); } else { - MOTPLS_LOG(ERR, TYPE_ALL, NO_ERRNO, "Bad programming"); + MOTION_LOG(ERR, TYPE_ALL, NO_ERRNO, "Bad programming"); } } } @@ -696,7 +697,7 @@ void cls_webu_post::ptz() for (indx=0;indxwb_actions->params_cnt;indx++) { if (webu->wb_actions->params_array[indx].param_name == "ptz") { if (webu->wb_actions->params_array[indx].param_value == "off") { - MOTPLS_LOG(INF, TYPE_ALL, NO_ERRNO, "PTZ actions disabled"); + MOTION_LOG(INF, TYPE_ALL, NO_ERRNO, "PTZ actions disabled"); return; } else { break; @@ -761,12 +762,12 @@ void cls_webu_post::process_actions() action_snapshot(); } else if (post_cmd == "pause") { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("pause action deprecated. Use pause_on")); action_pause_on(); } else if (post_cmd == "unpause") { - MOTPLS_LOG(NTC, TYPE_STREAM, NO_ERRNO + MOTION_LOG(NTC, TYPE_STREAM, NO_ERRNO , _("unpause action deprecated. Use pause_off")); action_pause_off(); @@ -810,7 +811,7 @@ void cls_webu_post::process_actions() ptz(); } else { - MOTPLS_LOG(INF, TYPE_STREAM, NO_ERRNO + MOTION_LOG(INF, TYPE_STREAM, NO_ERRNO , _("Invalid action requested: command: >%s< camindx : >%d< ") , post_cmd.c_str(), webua->camindx); } @@ -862,7 +863,7 @@ void cls_webu_post::iterate_post_new(const char *key post_info[post_sz-1].key_sz = datasz; if (retcd < 0) { - MOTPLS_LOG(INF, TYPE_STREAM, NO_ERRNO, _("Error processing post data")); + MOTION_LOG(INF, TYPE_STREAM, NO_ERRNO, _("Error processing post data")); } } @@ -889,6 +890,9 @@ mhdrslt cls_webu_post::processor_init() post_processor = MHD_create_post_processor (webua->connection , WEBUI_POST_BFRSZ, webup_iterate_post, (void *)this); if (post_processor == NULL) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("Failed to create POST processor for %s"), + webua->url.c_str()); return MHD_NO; } return MHD_YES; @@ -902,13 +906,51 @@ mhdrslt cls_webu_post::processor_start(const char *upload_data, size_t *upload_d retcd = MHD_post_process (post_processor, upload_data, *upload_data_size); *upload_data_size = 0; } else { + /* CSRF validation for URL-encoded POST: + * 1. Check if csrf_token was provided in POST data + * 2. If provided, validate it (supports session or global tokens) + * 3. If not provided, allow for backward compatibility (IoT devices/scripts) + * + * Security note: To enforce CSRF for URL-encoded POST, users can: + * - Add csrf_token parameter to their POST requests + * - Use JSON API instead (has mandatory CSRF) + */ + std::string csrf_token_received = ""; + + /* Check if csrf_token was included in POST data */ + for (int indx = 0; indx < post_sz; indx++) { + if (mystreq(post_info[indx].key_nm, "csrf_token")) { + csrf_token_received = post_info[indx].key_val; + break; + } + } + + /* If CSRF token was provided, validate it */ + if (!csrf_token_received.empty()) { + if (!webu->csrf_validate_request(csrf_token_received, webua->session_token)) { + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, + _("CSRF token validation failed from %s"), webua->clientip.c_str()); + webua->resp_page = "{\"status\":\"error\",\"message\":\"CSRF validation failed\"}"; + webua->resp_type = WEBUI_RESP_JSON; + webua->mhd_send(); + return MHD_YES; + } + MOTION_LOG(DBG, TYPE_STREAM, NO_ERRNO, + _("CSRF token validated for URL-encoded POST from %s"), webua->clientip.c_str()); + } else { + /* No CSRF token - allow for backward compatibility */ + MOTION_LOG(DBG, TYPE_STREAM, NO_ERRNO, + _("URL-encoded POST without CSRF token from %s (backward compatibility mode)"), + webua->clientip.c_str()); + } + pthread_mutex_lock(&app->mutex_post); process_actions(); pthread_mutex_unlock(&app->mutex_post); - /* Send updated page back to user */ - webu_html = new cls_webu_html(webua); - webu_html->main(); - delete webu_html; + /* Return JSON response for action result */ + webua->resp_type = WEBUI_RESP_JSON; + webua->resp_page = "{\"status\":\"ok\"}"; + webua->mhd_send(); retcd = MHD_YES; } return retcd; diff --git a/src/webu_post.hpp b/src/webu_post.hpp index d1d5ef20..0a065e44 100644 --- a/src/webu_post.hpp +++ b/src/webu_post.hpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_post.hpp - HTTP POST Handler Interface + * + * Header file defining HTTP POST request processing structures and + * functions for configuration updates, camera commands, and profile + * operations via JSON request bodies. + * + */ #ifndef _INCLUDE_WEBU_POST_HPP_ #define _INCLUDE_WEBU_POST_HPP_ @@ -32,12 +41,19 @@ mhdrslt iterate_post (const char *key, const char *data, size_t datasz); mhdrslt processor_init(); mhdrslt processor_start(const char *upload_data, size_t *upload_data_size); + void process_actions(); + void action_eventend(); + void action_eventstart(); + void action_snapshot(); + void action_pause_on(); + void action_pause_off(); + void action_restart(); + void action_stop(); private: cls_motapp *app; cls_webu *webu; cls_webu_ans *webua; - cls_webu_html *webu_html; std::string post_cmd; int post_sz; /* The number of entries in the post info */ @@ -50,15 +66,7 @@ void parse_cmd(); void iterate_post_append(int indx, const char *data, size_t datasz); void iterate_post_new(const char *key, const char *data, size_t datasz); - void process_actions(); - void action_eventend(); - void action_eventstart(); - void action_snapshot(); - void action_pause_on(); - void action_pause_off(); void action_pause_schedule(); - void action_restart(); - void action_stop(); void action_user(); void write_config(); void config_set(int indx_parm, std::string parm_val); diff --git a/src/webu_stream.cpp b/src/webu_stream.cpp index c65b11da..5051908d 100644 --- a/src/webu_stream.cpp +++ b/src/webu_stream.cpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_stream.cpp - MJPEG Streaming Implementation + * + * This module provides real-time MJPEG video streaming over HTTP, delivering + * live camera feeds to web browsers with minimal latency using multipart + * content responses. + * + */ #include "motion.hpp" #include "util.hpp" @@ -152,7 +161,7 @@ bool cls_webu_stream::all_ready() indx1++; } if (p_cam->passflag == false) { - MOTPLS_LOG(DBG, TYPE_STREAM, NO_ERRNO + MOTION_LOG(DBG, TYPE_STREAM, NO_ERRNO , "Camera %d not ready", p_cam->cfg->device_id); return false; } @@ -160,7 +169,7 @@ bool cls_webu_stream::all_ready() } if ((webua->app->allcam->all_sizes.dst_h == 0) || (webua->app->allcam->all_sizes.dst_w == 0)) { - MOTPLS_LOG(DBG, TYPE_STREAM, NO_ERRNO, "All cameras not ready"); + MOTION_LOG(DBG, TYPE_STREAM, NO_ERRNO, "All cameras not ready"); return false; } @@ -481,30 +490,6 @@ void cls_webu_stream::static_one_img() } -bool cls_webu_stream::valid_request() -{ - if (check_finish()) { - return false; - } - - pthread_mutex_lock(&app->mutex_camlst); - if (webua->device_id < 0) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO - , _("Invalid camera specified: %s"), webua->url.c_str()); - pthread_mutex_unlock(&app->mutex_camlst); - return false; - } - if ((webua->device_id > 0) && (webua->cam == NULL)) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO - , _("Invalid camera specified: %s"), webua->url.c_str()); - pthread_mutex_unlock(&app->mutex_camlst); - return false; - } - pthread_mutex_unlock(&app->mutex_camlst); - - return true; -} - /* Increment the transport stream counters */ void cls_webu_stream::ts_cnct() { @@ -600,7 +585,7 @@ mhdrslt cls_webu_stream::stream_mjpeg() response = MHD_create_response_from_callback (MHD_SIZE_UNKNOWN, 1024 , &webu_mjpeg_response, (void *)this, NULL); if (response == NULL) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); return MHD_NO; } @@ -630,7 +615,7 @@ mhdrslt cls_webu_stream::stream_static() int indx; if (resp_used == 0) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Could not get image to stream.")); + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Could not get image to stream.")); return MHD_NO; } @@ -638,7 +623,7 @@ mhdrslt cls_webu_stream::stream_static() resp_size,(void *)resp_image , MHD_RESPMEM_MUST_COPY); if (response == NULL) { - MOTPLS_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); + MOTION_LOG(ERR, TYPE_STREAM, NO_ERRNO, _("Invalid response")); return MHD_NO; } @@ -661,13 +646,12 @@ mhdrslt cls_webu_stream::stream_static() } /* Entry point for answering stream*/ -mhdrslt cls_webu_stream::main() +void cls_webu_stream::main() { - mhdrslt retcd; + mhdrslt retcd = MHD_NO; - if (valid_request() == false) { - webua->bad_request(); - return MHD_NO; + if (check_finish()) { + return; } set_cnct_type(); @@ -703,13 +687,12 @@ mhdrslt cls_webu_stream::main() if (retcd == MHD_NO) { mydelete(webu_mpegts); } + } - } else { + if (retcd == MHD_NO) { webua->bad_request(); - retcd = MHD_NO; } - return retcd; } cls_webu_stream::cls_webu_stream(cls_webu_ans *p_webua) diff --git a/src/webu_stream.hpp b/src/webu_stream.hpp index f5679727..eaa8f0b7 100644 --- a/src/webu_stream.hpp +++ b/src/webu_stream.hpp @@ -14,7 +14,16 @@ * You should have received a copy of the GNU General Public License * along with Motion. If not, see . * -*/ + */ + +/* + * webu_stream.hpp - MJPEG Streaming Interface + * + * Header file defining the MJPEG streaming class for delivering real-time + * video streams over HTTP using multipart content responses for live + * camera feeds in web browsers. + * + */ #ifndef _INCLUDE_WEBU_STREAM_HPP_ #define _INCLUDE_WEBU_STREAM_HPP_ @@ -28,7 +37,7 @@ size_t resp_used; /* The amount of the response page used */ u_char *resp_image; /* Response image to provide to user */ - mhdrslt main(); + void main(); ssize_t mjpeg_response (char *buf, size_t max); bool check_finish(); void delay(); @@ -53,7 +62,6 @@ mhdrslt stream_static(); mhdrslt stream_mjpeg(); - bool valid_request(); void all_cnct(); void jpg_cnct(); void ts_cnct(); diff --git a/test_urlencoded_post.sh b/test_urlencoded_post.sh new file mode 100755 index 00000000..006f8363 --- /dev/null +++ b/test_urlencoded_post.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Test script for URL-encoded POST commands +# This tests backward compatibility with the original Motion POST API + +HOST="${1:-http://localhost:8080}" +CAMID="${2:-1}" + +echo "Testing URL-encoded POST commands on $HOST for camera $CAMID" +echo "================================================================" +echo "" + +# Function to test a command +test_command() { + local cmd=$1 + local desc=$2 + echo -n "Testing $desc ($cmd)... " + response=$(curl -s -w "\n%{http_code}" -d "command=$cmd&camid=$CAMID" "$HOST") + http_code=$(echo "$response" | tail -n 1) + body=$(echo "$response" | head -n -1) + + if [ "$http_code" = "200" ]; then + echo "✓ OK (HTTP $http_code)" + if [ -n "$body" ]; then + echo " Response: $body" + fi + else + echo "✗ FAILED (HTTP $http_code)" + echo " Response: $body" + fi + echo "" +} + +# Test basic commands +test_command "snapshot" "Snapshot" +sleep 1 + +test_command "pause_on" "Pause detection" +sleep 1 + +test_command "pause_off" "Resume detection" +sleep 1 + +test_command "eventstart" "Event start" +sleep 1 + +test_command "eventend" "Event end" +sleep 1 + +# Test PTZ commands (if camera supports it) +echo "Testing PTZ commands (may fail if camera doesn't support PTZ):" +test_command "pan_left" "Pan left" +test_command "pan_right" "Pan right" +test_command "tilt_up" "Tilt up" +test_command "tilt_down" "Tilt down" + +# Test that JSON API still works +echo "" +echo "Verifying JSON API still works:" +echo "================================================================" +echo -n "Testing GET /1/api/config... " +json_response=$(curl -s -w "\n%{http_code}" -H "Content-Type: application/json" "$HOST/1/api/config") +json_http_code=$(echo "$json_response" | tail -n 1) +if [ "$json_http_code" = "200" ]; then + echo "✓ OK (HTTP $json_http_code)" +else + echo "✗ FAILED (HTTP $json_http_code)" +fi + +echo "" +echo "================================================================" +echo "Tests complete!"