forked from Github/frigate
Compare commits
19 Commits
v0.10.0-be
...
v0.10.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e84ca7fe | ||
|
|
e6ec5cb097 | ||
|
|
23c70acd51 | ||
|
|
091648187f | ||
|
|
2b7d38f947 | ||
|
|
f801930588 | ||
|
|
955c2779d9 | ||
|
|
037f8667a6 | ||
|
|
307068a61f | ||
|
|
077d900b44 | ||
|
|
92f9195075 | ||
|
|
82c60093d1 | ||
|
|
944b9181e0 | ||
|
|
326b368e82 | ||
|
|
040d8c9778 | ||
|
|
273f803c7c | ||
|
|
bd8e23833c | ||
|
|
9edf38347c | ||
|
|
1569ce7cf6 |
24
.github/workflows/pull_request.yml
vendored
24
.github/workflows/pull_request.yml
vendored
@@ -44,3 +44,27 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm run test
|
run: npm run test
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
|
||||||
|
docker_tests_on_aarch64:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Build and run tests
|
||||||
|
run: make run_tests PLATFORM="linux/arm64/v8" ARCH="aarch64"
|
||||||
|
|
||||||
|
docker_tests_on_amd64:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Build and run tests
|
||||||
|
run: make run_tests PLATFORM="linux/amd64" ARCH="amd64"
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -59,4 +59,16 @@ armv7_frigate: version web
|
|||||||
|
|
||||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||||
|
|
||||||
.PHONY: web
|
run_tests:
|
||||||
|
# PLATFORM: linux/arm64/v8 linux/amd64 or linux/arm/v7
|
||||||
|
# ARCH: aarch64 amd64 or armv7
|
||||||
|
@cat docker/Dockerfile.base docker/Dockerfile.$(ARCH) > docker/Dockerfile.test
|
||||||
|
@sed -i "s/FROM frigate-web as web/#/g" docker/Dockerfile.test
|
||||||
|
@sed -i "s/COPY --from=web \/opt\/frigate\/build web\//#/g" docker/Dockerfile.test
|
||||||
|
@sed -i "s/FROM frigate-base/#/g" docker/Dockerfile.test
|
||||||
|
@echo "" >> docker/Dockerfile.test
|
||||||
|
@echo "RUN python3 -m unittest" >> docker/Dockerfile.test
|
||||||
|
@docker buildx build --platform=$(PLATFORM) --tag frigate-base --build-arg NGINX_VERSION=1.0.2 --build-arg FFMPEG_VERSION=1.0.0 --build-arg ARCH=$(ARCH) --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.test .
|
||||||
|
@rm docker/Dockerfile.test
|
||||||
|
|
||||||
|
.PHONY: web run_tests
|
||||||
|
|||||||
@@ -159,8 +159,9 @@ detect:
|
|||||||
enabled: True
|
enabled: True
|
||||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
||||||
max_disappeared: 25
|
max_disappeared: 25
|
||||||
# Optional: Frequency for running detection on stationary objects (default: 10x the frame rate)
|
# Optional: Frequency for running detection on stationary objects (default: 0)
|
||||||
stationary_interval: 50
|
# When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame.
|
||||||
|
stationary_interval: 0
|
||||||
|
|
||||||
# Optional: Object configuration
|
# Optional: Object configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
@@ -224,6 +225,9 @@ motion:
|
|||||||
record:
|
record:
|
||||||
# Optional: Enable recording (default: shown below)
|
# Optional: Enable recording (default: shown below)
|
||||||
enabled: False
|
enabled: False
|
||||||
|
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
||||||
|
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
||||||
|
expire_interval: 60
|
||||||
# Optional: Retention settings for recording
|
# Optional: Retention settings for recording
|
||||||
retain:
|
retain:
|
||||||
# Optional: Number of days to retain recordings regardless of events (default: shown below)
|
# Optional: Number of days to retain recordings regardless of events (default: shown below)
|
||||||
@@ -264,7 +268,7 @@ record:
|
|||||||
# here, the segments will already be gone by the time this mode is applied.
|
# here, the segments will already be gone by the time this mode is applied.
|
||||||
# For example, if the camera retain mode is "motion", the segments without motion are
|
# For example, if the camera retain mode is "motion", the segments without motion are
|
||||||
# never stored, so setting the mode to "all" here won't bring them back.
|
# never stored, so setting the mode to "all" here won't bring them back.
|
||||||
mode: active_objects
|
mode: motion
|
||||||
# Optional: Per object retention days
|
# Optional: Per object retention days
|
||||||
objects:
|
objects:
|
||||||
person: 15
|
person: 15
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ class FrigateApp:
|
|||||||
self.config = user_config.runtime_config
|
self.config = user_config.runtime_config
|
||||||
|
|
||||||
for camera_name in self.config.cameras.keys():
|
for camera_name in self.config.cameras.keys():
|
||||||
# generage the ffmpeg commands
|
|
||||||
self.config.cameras[camera_name].create_ffmpeg_cmds()
|
|
||||||
|
|
||||||
# create camera_metrics
|
# create camera_metrics
|
||||||
self.camera_metrics[camera_name] = {
|
self.camera_metrics[camera_name] = {
|
||||||
"camera_fps": mp.Value("d", 0.0),
|
"camera_fps": mp.Value("d", 0.0),
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from pydantic import BaseModel, Extra, Field, validator
|
|||||||
from pydantic.fields import PrivateAttr
|
from pydantic.fields import PrivateAttr
|
||||||
|
|
||||||
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT
|
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT
|
||||||
from frigate.edgetpu import load_labels
|
from frigate.util import create_mask, deep_merge, load_labels
|
||||||
from frigate.util import create_mask, deep_merge
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,9 +72,7 @@ class RetainModeEnum(str, Enum):
|
|||||||
|
|
||||||
class RetainConfig(FrigateBaseModel):
|
class RetainConfig(FrigateBaseModel):
|
||||||
default: float = Field(default=10, title="Default retention period.")
|
default: float = Field(default=10, title="Default retention period.")
|
||||||
mode: RetainModeEnum = Field(
|
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||||
default=RetainModeEnum.active_objects, title="Retain mode."
|
|
||||||
)
|
|
||||||
objects: Dict[str, float] = Field(
|
objects: Dict[str, float] = Field(
|
||||||
default_factory=dict, title="Object retention period."
|
default_factory=dict, title="Object retention period."
|
||||||
)
|
)
|
||||||
@@ -104,6 +101,10 @@ class RecordRetainConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
class RecordConfig(FrigateBaseModel):
|
class RecordConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable record on all cameras.")
|
enabled: bool = Field(default=False, title="Enable record on all cameras.")
|
||||||
|
expire_interval: int = Field(
|
||||||
|
default=60,
|
||||||
|
title="Number of minutes to wait between cleanup runs.",
|
||||||
|
)
|
||||||
# deprecated - to be removed in a future version
|
# deprecated - to be removed in a future version
|
||||||
retain_days: Optional[float] = Field(title="Recording retention period in days.")
|
retain_days: Optional[float] = Field(title="Recording retention period in days.")
|
||||||
retain: RecordRetainConfig = Field(
|
retain: RecordRetainConfig = Field(
|
||||||
@@ -172,8 +173,9 @@ class DetectConfig(FrigateBaseModel):
|
|||||||
title="Maximum number of frames the object can dissapear before detection ends."
|
title="Maximum number of frames the object can dissapear before detection ends."
|
||||||
)
|
)
|
||||||
stationary_interval: Optional[int] = Field(
|
stationary_interval: Optional[int] = Field(
|
||||||
|
default=0,
|
||||||
title="Frame interval for checking stationary objects.",
|
title="Frame interval for checking stationary objects.",
|
||||||
ge=1,
|
ge=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +476,7 @@ class CameraLiveConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CameraConfig(FrigateBaseModel):
|
class CameraConfig(FrigateBaseModel):
|
||||||
name: Optional[str] = Field(title="Camera name.")
|
name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$")
|
||||||
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
|
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
|
||||||
best_image_timeout: int = Field(
|
best_image_timeout: int = Field(
|
||||||
default=60,
|
default=60,
|
||||||
@@ -538,6 +540,8 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
return self._ffmpeg_cmds
|
return self._ffmpeg_cmds
|
||||||
|
|
||||||
def create_ffmpeg_cmds(self):
|
def create_ffmpeg_cmds(self):
|
||||||
|
if "_ffmpeg_cmds" in self:
|
||||||
|
return
|
||||||
ffmpeg_cmds = []
|
ffmpeg_cmds = []
|
||||||
for ffmpeg_input in self.ffmpeg.inputs:
|
for ffmpeg_input in self.ffmpeg.inputs:
|
||||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||||
@@ -640,7 +644,7 @@ class ModelConfig(FrigateBaseModel):
|
|||||||
return self._merged_labelmap
|
return self._merged_labelmap
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def colormap(self) -> Dict[int, tuple[int, int, int]]:
|
def colormap(self) -> Dict[int, Tuple[int, int, int]]:
|
||||||
return self._colormap
|
return self._colormap
|
||||||
|
|
||||||
def __init__(self, **config):
|
def __init__(self, **config):
|
||||||
@@ -762,11 +766,6 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
if camera_config.detect.max_disappeared is None:
|
if camera_config.detect.max_disappeared is None:
|
||||||
camera_config.detect.max_disappeared = max_disappeared
|
camera_config.detect.max_disappeared = max_disappeared
|
||||||
|
|
||||||
# Default stationary_interval configuration
|
|
||||||
stationary_interval = camera_config.detect.fps * 10
|
|
||||||
if camera_config.detect.stationary_interval is None:
|
|
||||||
camera_config.detect.stationary_interval = stationary_interval
|
|
||||||
|
|
||||||
# FFMPEG input substitution
|
# FFMPEG input substitution
|
||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||||
@@ -846,7 +845,8 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied."
|
f"Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied."
|
||||||
)
|
)
|
||||||
|
# generage the ffmpeg commands
|
||||||
|
camera_config.create_ffmpeg_cmds()
|
||||||
config.cameras[name] = camera_config
|
config.cameras[name] = camera_config
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -13,31 +13,11 @@ import tflite_runtime.interpreter as tflite
|
|||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from tflite_runtime.interpreter import load_delegate
|
from tflite_runtime.interpreter import load_delegate
|
||||||
|
|
||||||
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen
|
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen, load_labels
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_labels(path, encoding="utf-8"):
|
|
||||||
"""Loads labels from file (with or without index numbers).
|
|
||||||
Args:
|
|
||||||
path: path to label file.
|
|
||||||
encoding: label file encoding.
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping indices to labels.
|
|
||||||
"""
|
|
||||||
with open(path, "r", encoding=encoding) as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
if not lines:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if lines[0].split(" ", maxsplit=1)[0].isdigit():
|
|
||||||
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
|
||||||
return {int(index): label.strip() for index, label in pairs}
|
|
||||||
else:
|
|
||||||
return {index: line.strip() for index, line in enumerate(lines)}
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectDetector(ABC):
|
class ObjectDetector(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, tensor_input, threshold=0.4):
|
def detect(self, tensor_input, threshold=0.4):
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ def delete_event(id):
|
|||||||
if event.has_snapshot:
|
if event.has_snapshot:
|
||||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
media.unlink(missing_ok=True)
|
media.unlink(missing_ok=True)
|
||||||
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||||
|
media.unlink(missing_ok=True)
|
||||||
if event.has_clip:
|
if event.has_clip:
|
||||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||||
media.unlink(missing_ok=True)
|
media.unlink(missing_ok=True)
|
||||||
@@ -182,7 +184,7 @@ def event_thumbnail(id):
|
|||||||
thumbnail_bytes = jpg.tobytes()
|
thumbnail_bytes = jpg.tobytes()
|
||||||
|
|
||||||
response = make_response(thumbnail_bytes)
|
response = make_response(thumbnail_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -223,7 +225,7 @@ def event_snapshot(id):
|
|||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
response = make_response(jpg_bytes)
|
response = make_response(jpg_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
if download:
|
if download:
|
||||||
response.headers[
|
response.headers[
|
||||||
"Content-Disposition"
|
"Content-Disposition"
|
||||||
@@ -359,9 +361,10 @@ def best(camera_name, label):
|
|||||||
|
|
||||||
crop = bool(request.args.get("crop", 0, type=int))
|
crop = bool(request.args.get("crop", 0, type=int))
|
||||||
if crop:
|
if crop:
|
||||||
box = best_object.get("box", (0, 0, 300, 300))
|
box_size = 300
|
||||||
|
box = best_object.get("box", (0, 0, box_size, box_size))
|
||||||
region = calculate_region(
|
region = calculate_region(
|
||||||
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
best_frame.shape, box[0], box[1], box[2], box[3], box_size, multiplier=1.1
|
||||||
)
|
)
|
||||||
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
@@ -376,7 +379,7 @@ def best(camera_name, label):
|
|||||||
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
@@ -438,7 +441,7 @@ def latest_frame(camera_name):
|
|||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
+ str(rc)
|
+ str(rc)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("MQTT connected")
|
logger.debug("MQTT connected")
|
||||||
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
||||||
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
|
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
||||||
from frigate.edgetpu import load_labels
|
|
||||||
from frigate.util import (
|
from frigate.util import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
calculate_region,
|
calculate_region,
|
||||||
draw_box_with_label,
|
draw_box_with_label,
|
||||||
draw_timestamp,
|
draw_timestamp,
|
||||||
|
load_labels,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -178,6 +178,7 @@ class TrackedObject:
|
|||||||
"area": self.obj_data["area"],
|
"area": self.obj_data["area"],
|
||||||
"region": self.obj_data["region"],
|
"region": self.obj_data["region"],
|
||||||
"motionless_count": self.obj_data["motionless_count"],
|
"motionless_count": self.obj_data["motionless_count"],
|
||||||
|
"position_changes": self.obj_data["position_changes"],
|
||||||
"current_zones": self.current_zones.copy(),
|
"current_zones": self.current_zones.copy(),
|
||||||
"entered_zones": self.entered_zones.copy(),
|
"entered_zones": self.entered_zones.copy(),
|
||||||
"has_clip": self.has_clip,
|
"has_clip": self.has_clip,
|
||||||
@@ -264,8 +265,15 @@ class TrackedObject:
|
|||||||
|
|
||||||
if crop:
|
if crop:
|
||||||
box = self.thumbnail_data["box"]
|
box = self.thumbnail_data["box"]
|
||||||
|
box_size = 300
|
||||||
region = calculate_region(
|
region = calculate_region(
|
||||||
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
best_frame.shape,
|
||||||
|
box[0],
|
||||||
|
box[1],
|
||||||
|
box[2],
|
||||||
|
box[3],
|
||||||
|
box_size,
|
||||||
|
multiplier=1.1,
|
||||||
)
|
)
|
||||||
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
@@ -731,6 +739,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if not snapshot_config.enabled:
|
if not snapshot_config.enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# object never changed position
|
||||||
|
if obj.obj_data["position_changes"] == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
# if there are required zones and there is no overlap
|
# if there are required zones and there is no overlap
|
||||||
required_zones = snapshot_config.required_zones
|
required_zones = snapshot_config.required_zones
|
||||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
@@ -751,6 +763,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if not record_config.enabled:
|
if not record_config.enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# object never changed position
|
||||||
|
if obj.obj_data["position_changes"] == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
# If there are required zones and there is no overlap
|
# If there are required zones and there is no overlap
|
||||||
required_zones = record_config.events.required_zones
|
required_zones = record_config.events.required_zones
|
||||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
@@ -772,6 +788,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
|
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
|
||||||
|
# object never changed position
|
||||||
|
if obj.obj_data["position_changes"] == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
# if there are required zones and there is no overlap
|
# if there are required zones and there is no overlap
|
||||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
required_zones = self.config.cameras[camera].mqtt.required_zones
|
||||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ class ObjectTracker:
|
|||||||
def __init__(self, config: DetectConfig):
|
def __init__(self, config: DetectConfig):
|
||||||
self.tracked_objects = {}
|
self.tracked_objects = {}
|
||||||
self.disappeared = {}
|
self.disappeared = {}
|
||||||
|
self.positions = {}
|
||||||
self.max_disappeared = config.max_disappeared
|
self.max_disappeared = config.max_disappeared
|
||||||
|
self.detect_config = config
|
||||||
|
|
||||||
def register(self, index, obj):
|
def register(self, index, obj):
|
||||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
@@ -28,24 +30,83 @@ class ObjectTracker:
|
|||||||
obj["id"] = id
|
obj["id"] = id
|
||||||
obj["start_time"] = obj["frame_time"]
|
obj["start_time"] = obj["frame_time"]
|
||||||
obj["motionless_count"] = 0
|
obj["motionless_count"] = 0
|
||||||
|
obj["position_changes"] = 0
|
||||||
self.tracked_objects[id] = obj
|
self.tracked_objects[id] = obj
|
||||||
self.disappeared[id] = 0
|
self.disappeared[id] = 0
|
||||||
|
self.positions[id] = {
|
||||||
|
"xmins": [],
|
||||||
|
"ymins": [],
|
||||||
|
"xmaxs": [],
|
||||||
|
"ymaxs": [],
|
||||||
|
"xmin": 0,
|
||||||
|
"ymin": 0,
|
||||||
|
"xmax": self.detect_config.width,
|
||||||
|
"ymax": self.detect_config.height,
|
||||||
|
}
|
||||||
|
|
||||||
def deregister(self, id):
|
def deregister(self, id):
|
||||||
del self.tracked_objects[id]
|
del self.tracked_objects[id]
|
||||||
del self.disappeared[id]
|
del self.disappeared[id]
|
||||||
|
|
||||||
|
# tracks the current position of the object based on the last 10 bounding boxes
|
||||||
|
# returns False if the object has moved outside its previous position
|
||||||
|
def update_position(self, id, box):
|
||||||
|
position = self.positions[id]
|
||||||
|
position_box = (
|
||||||
|
position["xmin"],
|
||||||
|
position["ymin"],
|
||||||
|
position["xmax"],
|
||||||
|
position["ymax"],
|
||||||
|
)
|
||||||
|
|
||||||
|
xmin, ymin, xmax, ymax = box
|
||||||
|
|
||||||
|
iou = intersection_over_union(position_box, box)
|
||||||
|
|
||||||
|
# if the iou drops below the threshold
|
||||||
|
# assume the object has moved to a new position and reset the computed box
|
||||||
|
if iou < 0.6:
|
||||||
|
self.positions[id] = {
|
||||||
|
"xmins": [xmin],
|
||||||
|
"ymins": [ymin],
|
||||||
|
"xmaxs": [xmax],
|
||||||
|
"ymaxs": [ymax],
|
||||||
|
"xmin": xmin,
|
||||||
|
"ymin": ymin,
|
||||||
|
"xmax": xmax,
|
||||||
|
"ymax": ymax,
|
||||||
|
}
|
||||||
|
return False
|
||||||
|
|
||||||
|
# if there are less than 10 entries for the position, add the bounding box
|
||||||
|
# and recompute the position box
|
||||||
|
if len(position["xmins"]) < 10:
|
||||||
|
position["xmins"].append(xmin)
|
||||||
|
position["ymins"].append(ymin)
|
||||||
|
position["xmaxs"].append(xmax)
|
||||||
|
position["ymaxs"].append(ymax)
|
||||||
|
# by using percentiles here, we hopefully remove outliers
|
||||||
|
position["xmin"] = np.percentile(position["xmins"], 15)
|
||||||
|
position["ymin"] = np.percentile(position["ymins"], 15)
|
||||||
|
position["xmax"] = np.percentile(position["xmaxs"], 85)
|
||||||
|
position["ymax"] = np.percentile(position["ymaxs"], 85)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def update(self, id, new_obj):
|
def update(self, id, new_obj):
|
||||||
self.disappeared[id] = 0
|
self.disappeared[id] = 0
|
||||||
if (
|
# update the motionless count if the object has not moved to a new position
|
||||||
intersection_over_union(self.tracked_objects[id]["box"], new_obj["box"])
|
if self.update_position(id, new_obj["box"]):
|
||||||
> 0.9
|
|
||||||
):
|
|
||||||
self.tracked_objects[id]["motionless_count"] += 1
|
self.tracked_objects[id]["motionless_count"] += 1
|
||||||
else:
|
else:
|
||||||
self.tracked_objects[id]["motionless_count"] = 0
|
self.tracked_objects[id]["motionless_count"] = 0
|
||||||
|
self.tracked_objects[id]["position_changes"] += 1
|
||||||
self.tracked_objects[id].update(new_obj)
|
self.tracked_objects[id].update(new_obj)
|
||||||
|
|
||||||
|
def update_frame_times(self, frame_time):
|
||||||
|
for id in self.tracked_objects.keys():
|
||||||
|
self.tracked_objects[id]["frame_time"] = frame_time
|
||||||
|
|
||||||
def match_and_update(self, frame_time, new_objects):
|
def match_and_update(self, frame_time, new_objects):
|
||||||
# group by name
|
# group by name
|
||||||
new_object_groups = defaultdict(lambda: [])
|
new_object_groups = defaultdict(lambda: [])
|
||||||
|
|||||||
@@ -497,7 +497,8 @@ class RecordingCleanup(threading.Thread):
|
|||||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
oldest_timestamp = datetime.datetime.now().timestamp()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Unable to find file from recordings database: {p}")
|
logger.warning(f"Unable to find file from recordings database: {p}")
|
||||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
||||||
process = sp.run(
|
process = sp.run(
|
||||||
@@ -548,7 +549,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
# self.sync_recordings()
|
# self.sync_recordings()
|
||||||
|
|
||||||
# Expire tmp clips every minute, recordings and clean directories every hour.
|
# Expire tmp clips every minute, recordings and clean directories every hour.
|
||||||
for counter in itertools.cycle(range(60)):
|
for counter in itertools.cycle(range(self.config.record.expire_interval)):
|
||||||
if self.stop_event.wait(60):
|
if self.stop_event.wait(60):
|
||||||
logger.info(f"Exiting recording cleanup...")
|
logger.info(f"Exiting recording cleanup...")
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert runtime_config.cameras["back"].motion.frame_height >= 120
|
assert runtime_config.cameras["back"].motion.frame_height == 50
|
||||||
|
|
||||||
def test_motion_contour_area_dynamic(self):
|
def test_motion_contour_area_dynamic(self):
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 99
|
assert round(runtime_config.cameras["back"].motion.contour_area) == 30
|
||||||
|
|
||||||
def test_merge_labelmap(self):
|
def test_merge_labelmap(self):
|
||||||
|
|
||||||
@@ -1244,6 +1244,30 @@ class TestConfig(unittest.TestCase):
|
|||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
||||||
|
|
||||||
|
def test_fails_on_bad_camera_name(self):
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"snapshots": {"retain": {"default": 1.5}},
|
||||||
|
"cameras": {
|
||||||
|
"back camer#": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
|
||||||
|
self.assertRaises(
|
||||||
|
ValidationError, lambda: frigate_config.runtime_config.cameras
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import cv2
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from unittest import TestCase, main
|
from unittest import TestCase, main
|
||||||
from frigate.video import box_overlaps, reduce_boxes
|
from frigate.video import box_overlaps, reduce_boxes
|
||||||
|
|||||||
@@ -189,12 +189,12 @@ def draw_box_with_label(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
|
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, model_size, multiplier=2):
|
||||||
# size is the longest edge and divisible by 4
|
# size is the longest edge and divisible by 4
|
||||||
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
||||||
# dont go any smaller than 300
|
# dont go any smaller than the model_size
|
||||||
if size < 300:
|
if size < model_size:
|
||||||
size = 300
|
size = model_size
|
||||||
|
|
||||||
# x_offset is midpoint of bounding box minus half the size
|
# x_offset is midpoint of bounding box minus half the size
|
||||||
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
|
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
|
||||||
@@ -601,6 +601,24 @@ def add_mask(mask, mask_img):
|
|||||||
)
|
)
|
||||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||||
|
|
||||||
|
def load_labels(path, encoding="utf-8"):
|
||||||
|
"""Loads labels from file (with or without index numbers).
|
||||||
|
Args:
|
||||||
|
path: path to label file.
|
||||||
|
encoding: label file encoding.
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping indices to labels.
|
||||||
|
"""
|
||||||
|
with open(path, "r", encoding=encoding) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
if not lines:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if lines[0].split(" ", maxsplit=1)[0].isdigit():
|
||||||
|
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
||||||
|
return {int(index): label.strip() for index, label in pairs}
|
||||||
|
else:
|
||||||
|
return {index: line.strip() for index, line in enumerate(lines)}
|
||||||
|
|
||||||
class FrameManager(ABC):
|
class FrameManager(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
114
frigate/video.py
114
frigate/video.py
@@ -3,6 +3,7 @@ import itertools
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import queue
|
import queue
|
||||||
|
import random
|
||||||
import signal
|
import signal
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
@@ -469,6 +470,8 @@ def process_frames(
|
|||||||
fps_tracker = EventsPerSecond()
|
fps_tracker = EventsPerSecond()
|
||||||
fps_tracker.start()
|
fps_tracker.start()
|
||||||
|
|
||||||
|
startup_scan_counter = 0
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
if exit_on_empty and frame_queue.empty():
|
if exit_on_empty and frame_queue.empty():
|
||||||
logger.info(f"Exiting track_objects...")
|
logger.info(f"Exiting track_objects...")
|
||||||
@@ -512,7 +515,10 @@ def process_frames(
|
|||||||
# if there hasn't been motion for 10 frames
|
# if there hasn't been motion for 10 frames
|
||||||
if obj["motionless_count"] >= 10
|
if obj["motionless_count"] >= 10
|
||||||
# and it isn't due for a periodic check
|
# and it isn't due for a periodic check
|
||||||
and obj["motionless_count"] % detect_config.stationary_interval != 0
|
and (
|
||||||
|
detect_config.stationary_interval == 0
|
||||||
|
or obj["motionless_count"] % detect_config.stationary_interval != 0
|
||||||
|
)
|
||||||
# and it hasn't disappeared
|
# and it hasn't disappeared
|
||||||
and object_tracker.disappeared[obj["id"]] == 0
|
and object_tracker.disappeared[obj["id"]] == 0
|
||||||
# and it doesn't overlap with any current motion boxes
|
# and it doesn't overlap with any current motion boxes
|
||||||
@@ -529,18 +535,42 @@ def process_frames(
|
|||||||
# combine motion boxes with known locations of existing objects
|
# combine motion boxes with known locations of existing objects
|
||||||
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
||||||
|
|
||||||
|
region_min_size = max(model_shape[0], model_shape[1])
|
||||||
# compute regions
|
# compute regions
|
||||||
regions = [
|
regions = [
|
||||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
|
calculate_region(
|
||||||
|
frame_shape,
|
||||||
|
a[0],
|
||||||
|
a[1],
|
||||||
|
a[2],
|
||||||
|
a[3],
|
||||||
|
region_min_size,
|
||||||
|
multiplier=random.uniform(1.2, 1.5),
|
||||||
|
)
|
||||||
for a in combined_boxes
|
for a in combined_boxes
|
||||||
]
|
]
|
||||||
|
|
||||||
# consolidate regions with heavy overlap
|
# consolidate regions with heavy overlap
|
||||||
regions = [
|
regions = [
|
||||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
calculate_region(
|
||||||
|
frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0
|
||||||
|
)
|
||||||
for a in reduce_boxes(regions, 0.4)
|
for a in reduce_boxes(regions, 0.4)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# if starting up, get the next startup scan region
|
||||||
|
if startup_scan_counter < 9:
|
||||||
|
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3)
|
||||||
|
ymax = int(frame_shape[0] / 3 + ymin)
|
||||||
|
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3)
|
||||||
|
xmax = int(frame_shape[1] / 3 + xmin)
|
||||||
|
regions.append(
|
||||||
|
calculate_region(
|
||||||
|
frame_shape, xmin, ymin, xmax, ymax, region_min_size, multiplier=1.2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
startup_scan_counter += 1
|
||||||
|
|
||||||
# resize regions and detect
|
# resize regions and detect
|
||||||
# seed with stationary objects
|
# seed with stationary objects
|
||||||
detections = [
|
detections = [
|
||||||
@@ -554,6 +584,7 @@ def process_frames(
|
|||||||
for obj in object_tracker.tracked_objects.values()
|
for obj in object_tracker.tracked_objects.values()
|
||||||
if obj["id"] in stationary_object_ids
|
if obj["id"] in stationary_object_ids
|
||||||
]
|
]
|
||||||
|
|
||||||
for region in regions:
|
for region in regions:
|
||||||
detections.extend(
|
detections.extend(
|
||||||
detect(
|
detect(
|
||||||
@@ -569,7 +600,7 @@ def process_frames(
|
|||||||
#########
|
#########
|
||||||
# merge objects, check for clipped objects and look again up to 4 times
|
# merge objects, check for clipped objects and look again up to 4 times
|
||||||
#########
|
#########
|
||||||
refining = True
|
refining = len(regions) > 0
|
||||||
refine_count = 0
|
refine_count = 0
|
||||||
while refining and refine_count < 4:
|
while refining and refine_count < 4:
|
||||||
refining = False
|
refining = False
|
||||||
@@ -596,7 +627,7 @@ def process_frames(
|
|||||||
box = obj[2]
|
box = obj[2]
|
||||||
# calculate a new region that will hopefully get the entire object
|
# calculate a new region that will hopefully get the entire object
|
||||||
region = calculate_region(
|
region = calculate_region(
|
||||||
frame_shape, box[0], box[1], box[2], box[3]
|
frame_shape, box[0], box[1], box[2], box[3], region_min_size
|
||||||
)
|
)
|
||||||
|
|
||||||
regions.append(region)
|
regions.append(region)
|
||||||
@@ -624,44 +655,49 @@ def process_frames(
|
|||||||
|
|
||||||
## drop detections that overlap too much
|
## drop detections that overlap too much
|
||||||
consolidated_detections = []
|
consolidated_detections = []
|
||||||
# group by name
|
|
||||||
detected_object_groups = defaultdict(lambda: [])
|
|
||||||
for detection in detections:
|
|
||||||
detected_object_groups[detection[0]].append(detection)
|
|
||||||
|
|
||||||
# loop over detections grouped by label
|
# if detection was run on this frame, consolidate
|
||||||
for group in detected_object_groups.values():
|
if len(regions) > 0:
|
||||||
# if the group only has 1 item, skip
|
# group by name
|
||||||
if len(group) == 1:
|
detected_object_groups = defaultdict(lambda: [])
|
||||||
consolidated_detections.append(group[0])
|
for detection in detections:
|
||||||
continue
|
detected_object_groups[detection[0]].append(detection)
|
||||||
|
|
||||||
# sort smallest to largest by area
|
# loop over detections grouped by label
|
||||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
for group in detected_object_groups.values():
|
||||||
|
# if the group only has 1 item, skip
|
||||||
|
if len(group) == 1:
|
||||||
|
consolidated_detections.append(group[0])
|
||||||
|
continue
|
||||||
|
|
||||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
# sort smallest to largest by area
|
||||||
current_detection = sorted_by_area[current_detection_idx][2]
|
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||||
overlap = 0
|
|
||||||
for to_check_idx in range(
|
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
current_detection = sorted_by_area[current_detection_idx][2]
|
||||||
len(sorted_by_area),
|
overlap = 0
|
||||||
):
|
for to_check_idx in range(
|
||||||
to_check = sorted_by_area[to_check_idx][2]
|
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||||
# if 90% of smaller detection is inside of another detection, consolidate
|
len(sorted_by_area),
|
||||||
if (
|
|
||||||
area(intersection(current_detection, to_check))
|
|
||||||
/ area(current_detection)
|
|
||||||
> 0.9
|
|
||||||
):
|
):
|
||||||
overlap = 1
|
to_check = sorted_by_area[to_check_idx][2]
|
||||||
break
|
# if 90% of smaller detection is inside of another detection, consolidate
|
||||||
if overlap == 0:
|
if (
|
||||||
consolidated_detections.append(
|
area(intersection(current_detection, to_check))
|
||||||
sorted_by_area[current_detection_idx]
|
/ area(current_detection)
|
||||||
)
|
> 0.9
|
||||||
|
):
|
||||||
# now that we have refined our detections, we need to track objects
|
overlap = 1
|
||||||
object_tracker.match_and_update(frame_time, consolidated_detections)
|
break
|
||||||
|
if overlap == 0:
|
||||||
|
consolidated_detections.append(
|
||||||
|
sorted_by_area[current_detection_idx]
|
||||||
|
)
|
||||||
|
# now that we have refined our detections, we need to track objects
|
||||||
|
object_tracker.match_and_update(frame_time, consolidated_detections)
|
||||||
|
# else, just update the frame times for the stationary objects
|
||||||
|
else:
|
||||||
|
object_tracker.update_frame_times(frame_time)
|
||||||
|
|
||||||
# add to the queue if not full
|
# add to the queue if not full
|
||||||
if detected_objects_queue.full():
|
if detected_objects_queue.full():
|
||||||
|
|||||||
329
web/src/components/Calender.jsx
Normal file
329
web/src/components/Calender.jsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||||
|
import ArrowRight from '../icons/ArrowRight';
|
||||||
|
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||||
|
|
||||||
|
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||||
|
|
||||||
|
const Calender = ({ onChange, calenderRef, close }) => {
|
||||||
|
const keyRef = useRef([]);
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
|
||||||
|
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||||
|
const monthMap = useMemo(
|
||||||
|
() => [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [state, setState] = useState({
|
||||||
|
getMonthDetails: [],
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
selectedDay: null,
|
||||||
|
timeRange: { before: null, after: null },
|
||||||
|
monthDetails: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getNumberOfDays = useCallback((year, month) => {
|
||||||
|
return 40 - new Date(year, month, 40).getDate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDayDetails = useCallback(
|
||||||
|
(args) => {
|
||||||
|
const date = args.index - args.firstDay;
|
||||||
|
const day = args.index % 7;
|
||||||
|
let prevMonth = args.month - 1;
|
||||||
|
let prevYear = args.year;
|
||||||
|
if (prevMonth < 0) {
|
||||||
|
prevMonth = 11;
|
||||||
|
prevYear--;
|
||||||
|
}
|
||||||
|
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||||
|
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||||
|
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||||
|
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||||
|
return {
|
||||||
|
date: _date,
|
||||||
|
day,
|
||||||
|
month,
|
||||||
|
timestamp,
|
||||||
|
dayString: daysMap[day],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[getNumberOfDays, daysMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMonthDetails = useCallback(
|
||||||
|
(year, month) => {
|
||||||
|
const firstDay = new Date(year, month).getDay();
|
||||||
|
const numberOfDays = getNumberOfDays(year, month);
|
||||||
|
const monthArray = [];
|
||||||
|
const rows = 6;
|
||||||
|
let currentDay = null;
|
||||||
|
let index = 0;
|
||||||
|
const cols = 7;
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
currentDay = getDayDetails({
|
||||||
|
index,
|
||||||
|
numberOfDays,
|
||||||
|
firstDay,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
monthArray.push(currentDay);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return monthArray;
|
||||||
|
},
|
||||||
|
[getNumberOfDays, getDayDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
|
||||||
|
}, [year, month, getMonthDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// add refs for keyboard navigation
|
||||||
|
if (state.monthDetails) {
|
||||||
|
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||||
|
}
|
||||||
|
// set today date in focus for keyboard navigation
|
||||||
|
const todayDate = new Date(todayTimestamp).getDate();
|
||||||
|
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||||
|
}, [state.monthDetails]);
|
||||||
|
|
||||||
|
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||||
|
|
||||||
|
const isSelectedRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||||
|
|
||||||
|
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
||||||
|
},
|
||||||
|
[state.timeRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirstDayInRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
if (isCurrentDay(day)) return;
|
||||||
|
return state.timeRange.after === day.timestamp;
|
||||||
|
},
|
||||||
|
[state.timeRange.after]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastDayInRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
||||||
|
},
|
||||||
|
[state.timeRange.before]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMonthStr = useCallback(
|
||||||
|
(month) => {
|
||||||
|
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||||
|
},
|
||||||
|
[monthMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDateClick = (day) => {
|
||||||
|
const { before, after } = state.timeRange;
|
||||||
|
let timeRange = { before: null, after: null };
|
||||||
|
|
||||||
|
// user has selected a date < after, reset values
|
||||||
|
if (after === null || day.timestamp < after) {
|
||||||
|
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// user has selected a date > after
|
||||||
|
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||||
|
timeRange = {
|
||||||
|
after,
|
||||||
|
before:
|
||||||
|
day.timestamp >= todayTimestamp
|
||||||
|
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||||
|
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset values
|
||||||
|
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||||
|
timeRange = { before: null, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
timeRange,
|
||||||
|
selectedDay: day.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setYear = useCallback(
|
||||||
|
(offset) => {
|
||||||
|
const year = state.year + offset;
|
||||||
|
const month = state.month;
|
||||||
|
setState((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
year,
|
||||||
|
monthDetails: getMonthDetails(year, month),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[state.year, state.month, getMonthDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMonth = (offset) => {
|
||||||
|
let year = state.year;
|
||||||
|
let month = state.month + offset;
|
||||||
|
if (month === -1) {
|
||||||
|
month = 11;
|
||||||
|
year--;
|
||||||
|
} else if (month === 12) {
|
||||||
|
month = 0;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
setState((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
monthDetails: getMonthDetails(year, month),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e, day, index) => {
|
||||||
|
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||||
|
e.preventDefault();
|
||||||
|
day.month === 0 && onDateClick(day);
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
index > 0 && keyRef.current[index - 1].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
index < 41 && keyRef.current[index + 1].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
index > 6 && keyRef.current[index - 7].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
index < 36 && keyRef.current[index + 7].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const days =
|
||||||
|
state.monthDetails &&
|
||||||
|
state.monthDetails.map((day, idx) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onDateClick(day)}
|
||||||
|
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||||
|
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||||
|
tabIndex={day.month === 0 ? day.date : null}
|
||||||
|
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||||
|
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||||
|
}
|
||||||
|
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||||
|
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||||
|
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||||
|
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<div className="font-light">
|
||||||
|
<span className="text-gray-400">{day.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex justify-start flex-shrink">
|
||||||
|
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||||
|
<div key={i} className="w-12 text-xs font-light text-center">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-56">{days}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
|
||||||
|
<div className="py-4 px-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-1/6 relative flex justify-around">
|
||||||
|
<div
|
||||||
|
tabIndex={100}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setYear(-1)}
|
||||||
|
>
|
||||||
|
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around ">
|
||||||
|
<div
|
||||||
|
tabIndex={101}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setMonth(-1)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||||
|
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around ">
|
||||||
|
<div
|
||||||
|
tabIndex={102}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setMonth(1)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-2/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
|
||||||
|
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||||
|
<ArrowRightDouble className="h-2/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">{renderCalendar()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Calender;
|
||||||
162
web/src/components/DatePicker.jsx
Normal file
162
web/src/components/DatePicker.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export const DateFilterOptions = [
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
value: ['all'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Today',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Yesterday',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 7 Days',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'This Month',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last Month',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Custom Range',
|
||||||
|
value: 'custom_range',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DatePicker({
|
||||||
|
helpText,
|
||||||
|
keyboardType = 'text',
|
||||||
|
inputRef,
|
||||||
|
label,
|
||||||
|
leadingIcon: LeadingIcon,
|
||||||
|
onBlur,
|
||||||
|
onChangeText,
|
||||||
|
onFocus,
|
||||||
|
readonly,
|
||||||
|
trailingIcon: TrailingIcon,
|
||||||
|
value: propValue = '',
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [isFocused, setFocused] = useState(false);
|
||||||
|
const [value, setValue] = useState(propValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (propValue !== value) {
|
||||||
|
setValue(propValue);
|
||||||
|
}
|
||||||
|
}, [propValue, setValue, value]);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus && onFocus(event);
|
||||||
|
},
|
||||||
|
[onFocus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur && onBlur(event);
|
||||||
|
},
|
||||||
|
[onBlur]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setValue(value);
|
||||||
|
onChangeText && onChangeText(value);
|
||||||
|
},
|
||||||
|
[onChangeText, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = (e) => {
|
||||||
|
props.onclick(e);
|
||||||
|
};
|
||||||
|
const labelMoved = isFocused || value !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{props.children}
|
||||||
|
<div
|
||||||
|
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||||
|
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||||
|
}`}
|
||||||
|
ref={inputRef}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="flex space-x-2 items-center"
|
||||||
|
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||||
|
>
|
||||||
|
{LeadingIcon ? (
|
||||||
|
<div className="w-10 h-full">
|
||||||
|
<LeadingIcon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||||
|
type={keyboardType}
|
||||||
|
readOnly
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onInput={handleChange}
|
||||||
|
tabIndex="0"
|
||||||
|
onClick={onClick}
|
||||||
|
value={propValue}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||||
|
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||||
|
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||||
|
>
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{TrailingIcon ? (
|
||||||
|
<div className="w-10 h-10">
|
||||||
|
<TrailingIcon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export default function RelativeModal({
|
|||||||
|
|
||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const focusable = ref.current.querySelectorAll('[tabindex]');
|
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||||
if (event.key === 'Tab' && focusable.length) {
|
if (event.key === 'Tab' && focusable.length) {
|
||||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||||
focusable[focusable.length - 1].focus();
|
focusable[focusable.length - 1].focus();
|
||||||
@@ -69,14 +69,15 @@ export default function RelativeModal({
|
|||||||
let newTop = top;
|
let newTop = top;
|
||||||
let newLeft = left;
|
let newLeft = left;
|
||||||
|
|
||||||
// too far right
|
|
||||||
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
|
||||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
|
||||||
}
|
|
||||||
// too far left
|
// too far left
|
||||||
else if (left < WINDOW_PADDING) {
|
if (left < WINDOW_PADDING) {
|
||||||
newLeft = WINDOW_PADDING;
|
newLeft = WINDOW_PADDING;
|
||||||
}
|
}
|
||||||
|
// too far right
|
||||||
|
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||||
|
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
// too close to bottom
|
// too close to bottom
|
||||||
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
||||||
newTop = WINDOW_PADDING;
|
newTop = WINDOW_PADDING;
|
||||||
|
|||||||
@@ -3,74 +3,27 @@ import ArrowDropdown from '../icons/ArrowDropdown';
|
|||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
import Menu, { MenuItem } from './Menu';
|
import Menu, { MenuItem } from './Menu';
|
||||||
import TextField from './TextField';
|
import TextField from './TextField';
|
||||||
|
import DatePicker from './DatePicker';
|
||||||
|
import Calender from './Calender';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
|
export default function Select({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
paramName,
|
||||||
|
options: inputOptions = [],
|
||||||
|
selected: propSelected,
|
||||||
|
}) {
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() =>
|
() =>
|
||||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||||
[inputOptions]
|
[inputOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [selected, setSelected] = useState(
|
const [selected, setSelected] = useState();
|
||||||
Math.max(
|
const [datePickerValue, setDatePickerValue] = useState();
|
||||||
options.findIndex(({ value }) => value === propSelected),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const [focused, setFocused] = useState(null);
|
|
||||||
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(value, label) => {
|
|
||||||
setSelected(options.findIndex((opt) => opt.value === value));
|
|
||||||
onChange && onChange(value, label);
|
|
||||||
setShowMenu(false);
|
|
||||||
},
|
|
||||||
[onChange, options]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
setShowMenu(true);
|
|
||||||
}, [setShowMenu]);
|
|
||||||
|
|
||||||
const handleKeydown = useCallback(
|
|
||||||
(event) => {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Enter': {
|
|
||||||
if (!showMenu) {
|
|
||||||
setShowMenu(true);
|
|
||||||
setFocused(selected);
|
|
||||||
} else {
|
|
||||||
setSelected(focused);
|
|
||||||
onChange && onChange(options[focused].value, options[focused].label);
|
|
||||||
setShowMenu(false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ArrowDown': {
|
|
||||||
const newIndex = focused + 1;
|
|
||||||
newIndex < options.length && setFocused(newIndex);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ArrowUp': {
|
|
||||||
const newIndex = focused - 1;
|
|
||||||
newIndex > -1 && setFocused(newIndex);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// no default
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
setShowMenu(false);
|
|
||||||
}, [setShowMenu]);
|
|
||||||
|
|
||||||
// Reset the state if the prop value changes
|
// Reset the state if the prop value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,25 +38,219 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
|||||||
// DO NOT include `selected`
|
// DO NOT include `selected`
|
||||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<Fragment>
|
if (type === 'datepicker') {
|
||||||
<TextField
|
if ('after' && 'before' in propSelected) {
|
||||||
inputRef={ref}
|
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||||
label={label}
|
|
||||||
onchange={onChange}
|
for (let i = 0; i < inputOptions.length; i++) {
|
||||||
onclick={handleClick}
|
if (
|
||||||
onkeydown={handleKeydown}
|
inputOptions[i].value &&
|
||||||
readonly
|
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
) {
|
||||||
value={options[selected]?.label}
|
setDatePickerValue(inputOptions[i]?.label);
|
||||||
/>
|
break;
|
||||||
{showMenu ? (
|
} else {
|
||||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
setDatePickerValue(
|
||||||
{options.map(({ value, label }, i) => (
|
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
propSelected.before * 1000 - 1
|
||||||
))}
|
).toLocaleDateString()}`
|
||||||
</Menu>
|
);
|
||||||
) : null}
|
}
|
||||||
</Fragment>
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'dropdown') {
|
||||||
|
setSelected(
|
||||||
|
Math.max(
|
||||||
|
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||||
|
|
||||||
|
const [focused, setFocused] = useState(null);
|
||||||
|
const [showCalender, setShowCalender] = useState(false);
|
||||||
|
const calenderRef = useRef(null);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(value) => {
|
||||||
|
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||||
|
setShowMenu(false);
|
||||||
|
|
||||||
|
//show calender date range picker
|
||||||
|
if (value === 'custom_range') return setShowCalender(true);
|
||||||
|
onChange && onChange(value);
|
||||||
|
},
|
||||||
|
[onChange, options, propSelected, setSelected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDateRange = useCallback(
|
||||||
|
(range) => {
|
||||||
|
onChange && onChange(range);
|
||||||
|
setShowMenu(false);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setShowMenu(true);
|
||||||
|
}, [setShowMenu]);
|
||||||
|
|
||||||
|
const handleKeydownDatePicker = useCallback(
|
||||||
|
(event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter': {
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowMenu(true);
|
||||||
|
setFocused(selected);
|
||||||
|
} else {
|
||||||
|
setSelected(focused);
|
||||||
|
if (options[focused].value === 'custom_range') {
|
||||||
|
setShowMenu(false);
|
||||||
|
return setShowCalender(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange && onChange(options[focused].value);
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused + 1;
|
||||||
|
newIndex < options.length && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused - 1;
|
||||||
|
newIndex > -1 && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeydown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter': {
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowMenu(true);
|
||||||
|
setFocused(selected);
|
||||||
|
} else {
|
||||||
|
setSelected(focused);
|
||||||
|
onChange && onChange({ [paramName]: options[focused].value });
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused + 1;
|
||||||
|
newIndex < options.length && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused - 1;
|
||||||
|
newIndex > -1 && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
}, [setShowMenu]);
|
||||||
|
|
||||||
|
const findDOMNodes = (component) => {
|
||||||
|
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const addBackDrop = (e) => {
|
||||||
|
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
||||||
|
setShowCalender(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('click', addBackDrop);
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
window.removeEventListener('click', addBackDrop);
|
||||||
|
};
|
||||||
|
}, [showCalender]);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'datepicker':
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<DatePicker
|
||||||
|
inputRef={ref}
|
||||||
|
label={label}
|
||||||
|
onchange={onChange}
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydownDatePicker}
|
||||||
|
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||||
|
value={datePickerValue}
|
||||||
|
/>
|
||||||
|
{showCalender && (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||||
|
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
{showMenu ? (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||||
|
{options.map(({ value, label }, i) => (
|
||||||
|
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
// case 'dropdown':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<TextField
|
||||||
|
inputRef={ref}
|
||||||
|
label={label}
|
||||||
|
onchange={onChange}
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
readonly
|
||||||
|
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||||
|
value={options[selected]?.label}
|
||||||
|
/>
|
||||||
|
{showMenu ? (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||||
|
{options.map(({ value, label }, i) => (
|
||||||
|
<MenuItem
|
||||||
|
key={value}
|
||||||
|
label={label}
|
||||||
|
focus={focused === i}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
value={{ [paramName]: value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,40 @@ import { fireEvent, render, screen } from '@testing-library/preact';
|
|||||||
describe('Select', () => {
|
describe('Select', () => {
|
||||||
test('on focus, shows a menu', async () => {
|
test('on focus, shows a menu', async () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
render(
|
||||||
|
<Select
|
||||||
|
label="Tacos"
|
||||||
|
type="dropdown"
|
||||||
|
onChange={handleChange}
|
||||||
|
options={['all', 'tacos', 'burritos']}
|
||||||
|
paramName={['dinner']}
|
||||||
|
selected=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('textbox'));
|
fireEvent.click(screen.getByRole('textbox'));
|
||||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
|
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allows keyboard navigation', async () => {
|
test('allows keyboard navigation', async () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
render(
|
||||||
|
<Select
|
||||||
|
label="Tacos"
|
||||||
|
type="dropdown"
|
||||||
|
onChange={handleChange}
|
||||||
|
options={['tacos', 'burritos']}
|
||||||
|
paramName={['dinner']}
|
||||||
|
selected=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
@@ -29,6 +48,6 @@ describe('Select', () => {
|
|||||||
|
|
||||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const useSearchString = (limit, searchParams) => {
|
|||||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||||
searchParams.delete('limit');
|
searchParams.delete('limit');
|
||||||
searchParams.delete('include_thumbnails');
|
searchParams.delete('include_thumbnails');
|
||||||
searchParams.delete('before');
|
// removed deletion of "before" as its used by DatePicker
|
||||||
|
// searchParams.delete('before');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { searchString, setSearchString, removeDefaultSearchKeys };
|
return { searchString, setSearchString, removeDefaultSearchKeys };
|
||||||
|
|||||||
18
web/src/icons/ArrowLeft.jsx
Normal file
18
web/src/icons/ArrowLeft.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowLeft({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`fill-current ${className}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowLeft);
|
||||||
12
web/src/icons/ArrowRight.jsx
Normal file
12
web/src/icons/ArrowRight.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowRight({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowRight);
|
||||||
12
web/src/icons/ArrowRightDouble.jsx
Normal file
12
web/src/icons/ArrowRightDouble.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowRightDouble({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowRightDouble);
|
||||||
@@ -1,31 +1,26 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Select from '../../../components/Select';
|
import Select from '../../../components/Select';
|
||||||
import { useCallback, useMemo } from 'preact/hooks';
|
import { useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
const Filter = ({ onChange, searchParams, paramName, options }) => {
|
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(key) => {
|
(key) => {
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
if (key !== 'all') {
|
Object.keys(key).map((entries) => {
|
||||||
newParams.set(paramName, key);
|
if (key[entries] !== 'all') {
|
||||||
} else {
|
newParams.set(entries, key[entries]);
|
||||||
newParams.delete(paramName);
|
} else {
|
||||||
}
|
paramName.map((p) => newParams.delete(p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onChange(newParams);
|
onChange(newParams);
|
||||||
},
|
},
|
||||||
[searchParams, paramName, onChange]
|
[searchParams, paramName, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
const obj = {};
|
||||||
|
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
|
||||||
return (
|
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
|
||||||
<Select
|
}
|
||||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
|
||||||
onChange={handleSelect}
|
|
||||||
options={selectOptions}
|
|
||||||
selected={searchParams.get(paramName) || 'all'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Filter;
|
export default Filter;
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'preact/hooks';
|
|||||||
import Link from '../../../components/Link';
|
import Link from '../../../components/Link';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
|
|
||||||
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
|
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
||||||
|
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||||
|
searchParams.delete('limit');
|
||||||
|
searchParams.delete('include_thumbnails');
|
||||||
|
// searchParams.delete('before');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const href = useMemo(() => {
|
const href = useMemo(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set(paramName, name);
|
params.set(paramName, name);
|
||||||
@@ -27,6 +33,6 @@ const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeD
|
|||||||
{name}
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Filterable;
|
export default Filterable;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Filter from './filter';
|
import Filter from './filter';
|
||||||
import { useConfig } from '../../../api';
|
import { useConfig } from '../../../api';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
import { DateFilterOptions } from '../../../components/DatePicker';
|
||||||
|
import Button from '../../../components/Button';
|
||||||
|
|
||||||
const Filters = ({ onChange, searchParams }) => {
|
const Filters = ({ onChange, searchParams }) => {
|
||||||
|
const [viewFilters, setViewFilters] = useState(false);
|
||||||
const { data } = useConfig();
|
const { data } = useConfig();
|
||||||
|
|
||||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||||
|
|
||||||
const zones = useMemo(
|
const zones = useMemo(
|
||||||
@@ -27,12 +29,52 @@ const Filters = ({ onChange, searchParams }) => {
|
|||||||
}, data.objects?.track || [])
|
}, data.objects?.track || [])
|
||||||
.filter((value, i, self) => self.indexOf(value) === i);
|
.filter((value, i, self) => self.indexOf(value) === i);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex space-x-4">
|
<div>
|
||||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
<Button
|
||||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
onClick={() => setViewFilters(!viewFilters)}
|
||||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
className="block xs:hidden w-full mb-4 text-center"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
|
||||||
|
</Button>
|
||||||
|
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
|
||||||
|
<Filter
|
||||||
|
type="dropdown"
|
||||||
|
onChange={onChange}
|
||||||
|
options={['all', ...cameras]}
|
||||||
|
paramName={['camera']}
|
||||||
|
label="Camera"
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Filter
|
||||||
|
type="dropdown"
|
||||||
|
onChange={onChange}
|
||||||
|
options={['all', ...zones]}
|
||||||
|
paramName={['zone']}
|
||||||
|
label="Zone"
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Filter
|
||||||
|
type="dropdown"
|
||||||
|
onChange={onChange}
|
||||||
|
options={['all', ...labels]}
|
||||||
|
paramName={['label']}
|
||||||
|
label="Label"
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Filter
|
||||||
|
type="datepicker"
|
||||||
|
onChange={onChange}
|
||||||
|
options={DateFilterOptions}
|
||||||
|
paramName={['before', 'after']}
|
||||||
|
label="DatePicker"
|
||||||
|
searchParams={searchParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user