500 lines
17 KiB
JavaScript
500 lines
17 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||
import "./ImageGallery.css";
|
||
import moment from "moment";
|
||
import { useDispatch, useSelector } from "react-redux";
|
||
import { useModal } from "./ModalContext";
|
||
import ImagePop from "./ImagePop";
|
||
import Avatar from "../../components/common/Avatar";
|
||
import DateRangePicker from "../../components/common/DateRangePicker";
|
||
import eventBus from "../../services/eventBus";
|
||
import Breadcrumb from "../../components/common/Breadcrumb";
|
||
import { formatUTCToLocalTime } from "../../utils/dateUtils";
|
||
import useImageGallery from "../../hooks/useImageGallery";
|
||
import { useProjectName } from "../../hooks/useProjects";
|
||
import { setProjectId } from "../../slices/localVariablesSlice";
|
||
|
||
const SCROLL_THRESHOLD = 5;
|
||
|
||
const ImageGallery = () => {
|
||
const selectedProjectId = useSelector(
|
||
(store) => store.localVariables.projectId
|
||
);
|
||
const dispatch = useDispatch();
|
||
const { projectNames } = useProjectName();
|
||
|
||
// Auto-select a project on mount
|
||
useEffect(() => {
|
||
if (!selectedProjectId && projectNames?.length) {
|
||
dispatch(setProjectId(projectNames[0].id));
|
||
}
|
||
}, [selectedProjectId, projectNames, dispatch]);
|
||
|
||
// Filter states
|
||
const [selectedFilters, setSelectedFilters] = useState({
|
||
building: [],
|
||
floor: [],
|
||
activity: [],
|
||
uploadedBy: [],
|
||
workCategory: [],
|
||
workArea: [],
|
||
startDate: "",
|
||
endDate: "",
|
||
});
|
||
const [appliedFilters, setAppliedFilters] = useState({
|
||
buildingIds: null,
|
||
floorIds: null,
|
||
activityIds: null,
|
||
uploadedByIds: null,
|
||
workCategoryIds: null,
|
||
workAreaIds: null,
|
||
startDate: null,
|
||
endDate: null,
|
||
});
|
||
const [collapsedFilters, setCollapsedFilters] = useState({
|
||
dateRange: false,
|
||
building: false,
|
||
floor: false,
|
||
activity: false,
|
||
uploadedBy: false,
|
||
workCategory: false,
|
||
workArea: false,
|
||
});
|
||
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
|
||
const [hoveredImage, setHoveredImage] = useState(null);
|
||
|
||
const imageGroupRefs = useRef({});
|
||
const loaderRef = useRef(null);
|
||
const filterPanelRef = useRef(null);
|
||
const filterButtonRef = useRef(null);
|
||
const { openModal } = useModal();
|
||
|
||
const {
|
||
data,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isLoading,
|
||
isFetchingNextPage,
|
||
refetch,
|
||
} = useImageGallery(selectedProjectId, appliedFilters);
|
||
|
||
const images = data?.pages.flatMap((page) => page.data) || [];
|
||
|
||
useEffect(() => {
|
||
const handleClick = (e) => {
|
||
if (
|
||
filterPanelRef.current &&
|
||
!filterPanelRef.current.contains(e.target) &&
|
||
filterButtonRef.current &&
|
||
!filterButtonRef.current.contains(e.target)
|
||
) {
|
||
setIsFilterPanelOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedProjectId) refetch();
|
||
}, [selectedProjectId, appliedFilters, refetch]);
|
||
|
||
useEffect(() => {
|
||
const handler = (data) => {
|
||
if (data.projectId === selectedProjectId) refetch();
|
||
};
|
||
eventBus.on("image_gallery", handler);
|
||
return () => eventBus.off("image_gallery", handler);
|
||
}, [selectedProjectId, refetch]);
|
||
|
||
useEffect(() => {
|
||
if (!loaderRef.current) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => {
|
||
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
|
||
fetchNextPage();
|
||
}
|
||
},
|
||
{ rootMargin: "200px", threshold: 0.1 }
|
||
);
|
||
|
||
observer.observe(loaderRef.current);
|
||
|
||
return () => {
|
||
if (loaderRef.current) {
|
||
observer.unobserve(loaderRef.current);
|
||
}
|
||
};
|
||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||
|
||
|
||
// Utility: derive filter options
|
||
const getUniqueValues = useCallback(
|
||
(idKey, nameKey) => {
|
||
const m = new Map();
|
||
images.forEach((batch) => {
|
||
const id = idKey === "floorIds" ? batch.floorIds : batch[idKey];
|
||
if (id && batch[nameKey] && !m.has(id)) {
|
||
m.set(id, batch[nameKey]);
|
||
}
|
||
});
|
||
return [...m.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||
},
|
||
[images]
|
||
);
|
||
|
||
const getUploadedBy = useCallback(() => {
|
||
const m = new Map();
|
||
images.forEach((batch) => {
|
||
batch.documents.forEach((doc) => {
|
||
const name = `${doc.uploadedBy?.firstName || ""} ${
|
||
doc.uploadedBy?.lastName || ""
|
||
}`.trim();
|
||
if (doc.uploadedBy?.id && name && !m.has(doc.uploadedBy.id)) {
|
||
m.set(doc.uploadedBy.id, name);
|
||
}
|
||
});
|
||
});
|
||
return [...m.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||
}, [images]);
|
||
|
||
const buildings = getUniqueValues("buildingId", "buildingName");
|
||
const floors = getUniqueValues("floorIds", "floorName");
|
||
const activities = getUniqueValues("activityId", "activityName");
|
||
const workAreas = getUniqueValues("workAreaId", "workAreaName");
|
||
const workCategories = getUniqueValues("workCategoryId", "workCategoryName");
|
||
const uploadedByUsers = getUploadedBy();
|
||
|
||
const toggleFilter = useCallback((type, id, name) => {
|
||
setSelectedFilters((prev) => {
|
||
const arr = prev[type];
|
||
const exists = arr.some(([x]) => x === id);
|
||
const updated = exists
|
||
? arr.filter(([x]) => x !== id)
|
||
: [...arr, [id, name]];
|
||
return { ...prev, [type]: updated };
|
||
});
|
||
}, []);
|
||
|
||
const setDateRange = useCallback(({ startDate, endDate }) => {
|
||
setSelectedFilters((prev) => ({
|
||
...prev,
|
||
startDate: startDate || "",
|
||
endDate: endDate || "",
|
||
}));
|
||
}, []);
|
||
|
||
const toggleCollapse = useCallback((type) => {
|
||
setCollapsedFilters((prev) => ({ ...prev, [type]: !prev[type] }));
|
||
}, []);
|
||
|
||
const handleApplyFilters = useCallback(() => {
|
||
const payload = {
|
||
buildingIds: selectedFilters.building.map(([x]) => x) || null,
|
||
floorIds: selectedFilters.floor.map(([x]) => x) || null,
|
||
activityIds: selectedFilters.activity.map(([x]) => x) || null,
|
||
uploadedByIds: selectedFilters.uploadedBy.map(([x]) => x) || null,
|
||
workCategoryIds: selectedFilters.workCategory.map(([x]) => x) || null,
|
||
workAreaIds: selectedFilters.workArea.map(([x]) => x) || null,
|
||
startDate: selectedFilters.startDate || null,
|
||
endDate: selectedFilters.endDate || null,
|
||
};
|
||
const changed = Object.keys(payload).some((key) => {
|
||
const oldVal = appliedFilters[key],
|
||
newVal = payload[key];
|
||
return Array.isArray(oldVal)
|
||
? oldVal.length !== newVal.length ||
|
||
oldVal.some((x) => !newVal.includes(x))
|
||
: oldVal !== newVal;
|
||
});
|
||
if (changed) setAppliedFilters(payload);
|
||
}, [selectedFilters, appliedFilters]);
|
||
|
||
const handleClear = useCallback(() => {
|
||
setSelectedFilters({
|
||
building: [],
|
||
floor: [],
|
||
activity: [],
|
||
uploadedBy: [],
|
||
workCategory: [],
|
||
workArea: [],
|
||
startDate: "",
|
||
endDate: "",
|
||
});
|
||
setAppliedFilters({
|
||
buildingIds: null,
|
||
floorIds: null,
|
||
activityIds: null,
|
||
uploadedByIds: null,
|
||
workCategoryIds: null,
|
||
workAreaIds: null,
|
||
startDate: null,
|
||
endDate: null,
|
||
});
|
||
}, []);
|
||
|
||
const scrollLeft = useCallback(
|
||
(key) =>
|
||
imageGroupRefs.current[key]?.scrollBy({ left: -200, behavior: "smooth" }),
|
||
[]
|
||
);
|
||
const scrollRight = useCallback(
|
||
(key) =>
|
||
imageGroupRefs.current[key]?.scrollBy({ left: 200, behavior: "smooth" }),
|
||
[]
|
||
);
|
||
|
||
const renderCategory = (label, items, type) => (
|
||
<div
|
||
className={`dropdown my-2 ${collapsedFilters[type] ? "collapsed" : ""}`}
|
||
>
|
||
<div
|
||
className="dropdown-header bg-label-primary"
|
||
onClick={() => toggleCollapse(type)}
|
||
>
|
||
<strong>{label}</strong>
|
||
<div className="header-controls">
|
||
{((type === "dateRange" &&
|
||
(selectedFilters.startDate || selectedFilters.endDate)) ||
|
||
(type !== "dateRange" && selectedFilters[type]?.length > 0)) && (
|
||
<button
|
||
className="clear-button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedFilters((prev) => ({
|
||
...prev,
|
||
[type]: type === "dateRange" ? "" : [],
|
||
...(type === "dateRange" && { startDate: "", endDate: "" }),
|
||
}));
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
)}
|
||
<span className="collapse-icon">
|
||
{collapsedFilters[type] ? "+" : "-"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{!collapsedFilters[type] && (
|
||
<div className="dropdown-content">
|
||
{type === "dateRange" ? (
|
||
<DateRangePicker
|
||
onRangeChange={setDateRange}
|
||
endDateMode="today"
|
||
startDate={selectedFilters.startDate}
|
||
endDate={selectedFilters.endDate}
|
||
/>
|
||
) : (
|
||
items.map(([id, name]) => (
|
||
<label key={id}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedFilters[type].some(([x]) => x === id)}
|
||
onChange={() => toggleFilter(type, id, name)}
|
||
/>
|
||
{name}
|
||
</label>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className={`gallery-container container-fluid ${
|
||
isFilterPanelOpen ? "filter-panel-open-end" : ""
|
||
}`}
|
||
>
|
||
<Breadcrumb data={[{ label: "Home", link: "/" }, { label: "Gallery" }]} />
|
||
<div className="main-content">
|
||
<button
|
||
className={`filter-button btn-primary ${
|
||
isFilterPanelOpen ? "closed-icon" : ""
|
||
}`}
|
||
onClick={() => setIsFilterPanelOpen((p) => !p)}
|
||
ref={filterButtonRef}
|
||
>
|
||
{isFilterPanelOpen ? (
|
||
<i className="fa-solid fa-times fs-5" />
|
||
) : (
|
||
<i className="bx bx-slider-alt ms-1" />
|
||
)}
|
||
</button>
|
||
<div className="activity-section">
|
||
{isLoading ? (
|
||
<div className="text-center">
|
||
<p>Loading...</p>
|
||
</div>
|
||
) : images.length ? (
|
||
images.map((batch) => {
|
||
const doc = batch.documents[0];
|
||
const userName = `${doc.uploadedBy?.firstName || ""} ${
|
||
doc.uploadedBy?.lastName || ""
|
||
}`.trim();
|
||
const date = formatUTCToLocalTime(doc.uploadedAt);
|
||
const hasArrows = batch.documents.length > SCROLL_THRESHOLD;
|
||
return (
|
||
<div key={batch.batchId} className="grouped-section">
|
||
<div className="group-heading">
|
||
{/* Uploader Info */}
|
||
<div className="d-flex align-items-center mb-1">
|
||
<Avatar
|
||
size="xs"
|
||
firstName={doc.uploadedBy?.firstName}
|
||
lastName={doc.uploadedBy?.lastName}
|
||
className="me-2"
|
||
/>
|
||
<div className="d-flex flex-column align-items-start">
|
||
<strong className="user-name-text">{userName}</strong>
|
||
<span className="text-muted small">{date}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Location Info */}
|
||
<div className="location-line text-secondary">
|
||
<div className="d-flex align-items-center flex-wrap gap-1 text-secondary">
|
||
{" "}
|
||
<span className="d-flex align-items-center">
|
||
<span>{batch.buildingName}</span>
|
||
<i className="bx bx-chevron-right " />
|
||
</span>
|
||
<span className="d-flex align-items-center">
|
||
<span>{batch.floorName}</span>
|
||
<i className="bx bx-chevron-right m" />
|
||
</span>
|
||
<span className="d-flex align-items-center ">
|
||
<span>{batch.workAreaName || "Unknown"}</span>
|
||
<i className="bx bx-chevron-right " />
|
||
<span>{batch.activityName}</span>
|
||
</span>
|
||
</div>
|
||
{batch.workCategoryName && (
|
||
<span className="badge bg-label-primary ms-2">
|
||
{batch.workCategoryName}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="image-group-wrapper">
|
||
{hasArrows && (
|
||
<button
|
||
className="scroll-arrow left-arrow"
|
||
onClick={() => scrollLeft(batch.batchId)}
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
<div
|
||
className="image-group-horizontal"
|
||
ref={(el) => (imageGroupRefs.current[batch.batchId] = el)}
|
||
>
|
||
{batch.documents.map((d, i) => {
|
||
const hoverDate = moment(d.uploadedAt).format(
|
||
"DD-MM-YYYY"
|
||
);
|
||
const hoverTime = moment(d.uploadedAt).format(
|
||
"hh:mm A"
|
||
);
|
||
return (
|
||
<div
|
||
key={d.id}
|
||
className="image-card"
|
||
onClick={() =>
|
||
openModal(
|
||
<ImagePop batch={batch} initialIndex={i} />
|
||
)
|
||
}
|
||
onMouseEnter={() => setHoveredImage(d)}
|
||
onMouseLeave={() => setHoveredImage(null)}
|
||
>
|
||
<div className="image-wrapper">
|
||
<img src={d.url} alt={`Image ${i + 1}`} />
|
||
</div>
|
||
{hoveredImage === d && (
|
||
<div className="image-hover-description">
|
||
<p>
|
||
<strong>Date:</strong> {hoverDate}
|
||
</p>
|
||
<p>
|
||
<strong>Time:</strong> {hoverTime}
|
||
</p>
|
||
<p>
|
||
<strong>Activity:</strong>{" "}
|
||
{batch.activityName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{hasArrows && (
|
||
<button
|
||
className="scroll-arrow right-arrow"
|
||
onClick={() => scrollRight(batch.batchId)}
|
||
>
|
||
<i className="bx bx-chevron-right"></i>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<p className="text-center text-muted mt-5">
|
||
No images match the selected filters.
|
||
</p>
|
||
)}
|
||
<div ref={loaderRef}>
|
||
{isFetchingNextPage && hasNextPage && <p>Loading...</p>}
|
||
{!hasNextPage && !isLoading && images.length > 0 && (
|
||
<p className="text-muted">
|
||
You've reached the end of the images.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`offcanvas offcanvas-end ${isFilterPanelOpen ? "show" : ""}`}
|
||
ref={filterPanelRef}
|
||
>
|
||
<div className="offcanvas-header">
|
||
<h5>Filters</h5>
|
||
<button
|
||
className="btn-close"
|
||
onClick={() => setIsFilterPanelOpen(false)}
|
||
/>
|
||
</div>
|
||
<div className="filter-actions mt-auto mx-2">
|
||
<button className="btn btn-secondary btn-xs" onClick={handleClear}>
|
||
Clear All
|
||
</button>
|
||
<button
|
||
className="btn btn-primary btn-xs"
|
||
onClick={handleApplyFilters}
|
||
>
|
||
Apply Filters
|
||
</button>
|
||
</div>
|
||
<div className="offcanvas-body d-flex flex-column">
|
||
{renderCategory("Date Range", [], "dateRange")}
|
||
{renderCategory("Building", buildings, "building")}
|
||
{renderCategory("Floor", floors, "floor")}
|
||
{renderCategory("Work Area", workAreas, "workArea")}
|
||
{renderCategory("Activity", activities, "activity")}
|
||
{renderCategory("Uploaded By (User)", uploadedByUsers, "uploadedBy")}
|
||
{renderCategory("Work Category", workCategories, "workCategory")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ImageGallery;
|