refactor: reorganize asset management components and enhance search functionality

This commit is contained in:
2025-08-29 13:47:07 +05:30
parent b2311ab186
commit b6783f99d3
11 changed files with 384 additions and 478 deletions

View File

@@ -1,474 +0,0 @@
import React, { useEffect, useState } from "react";
import Search from "../../ui/inputs/Search";
import { getCategoryAsset } from "../../../services/factoryBuilder/asset/assets/getCategoryAsset";
import { fetchAssets } from "../../../services/marketplace/fetchAssets";
import {
useDecalStore,
useDroppedDecal,
useSelectedItem,
} from "../../../store/builder/store";
// images -------------------
import vehicle from "../../../assets/image/categories/vehicles.png";
import workStation from "../../../assets/image/categories/workStation.png";
import machines from "../../../assets/image/categories/machines.png";
import worker from "../../../assets/image/categories/worker.png";
import storage from "../../../assets/image/categories/storage.png";
import office from "../../../assets/image/categories/office.png";
import safety from "../../../assets/image/categories/safety.png";
import feneration from "../../../assets/image/categories/feneration.png";
import decal from "../../../assets/image/categories/decal.png";
import SkeletonUI from "../../templates/SkeletonUI";
import {
AlertIcon,
ArrowIcon,
DecalInfoIcon,
HangTagIcon,
NavigationIcon,
} from "../../icons/ExportCommonIcons";
import { getCategoryDecals } from "../../../services/factoryBuilder/asset/decals/getCategoryDecals";
// -------------------------------------
interface AssetProp {
filename: string;
thumbnail?: string;
category: string;
description?: string;
tags: string;
url?: string;
uploadDate?: number;
isArchieve?: boolean;
animated?: boolean;
price?: number;
CreatedBy?: string;
}
interface CategoryListProp {
assetImage?: string;
assetName?: string;
categoryImage: string;
category: string;
}
const Assets: React.FC = () => {
const { setSelectedItem } = useSelectedItem();
const { setDroppedDecal } = useDroppedDecal();
const [searchValue, setSearchValue] = useState<string>("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [categoryAssets, setCategoryAssets] = useState<AssetProp[]>([]);
const [decalAsset, setDecalAsset] = useState<any>();
const [filtereredAssets, setFiltereredAssets] = useState<AssetProp[] | []>(
[]
);
const [categoryList, setCategoryList] = useState<CategoryListProp[]>([]);
const [isLoading, setisLoading] = useState<boolean>(false); // Loading state for assets
const { selectedSubCategory, setSelectedSubCategory } = useDecalStore();
const handleSearchChange = (value: string) => {
const searchTerm = searchValue
? searchValue.toLowerCase()
: value.toLowerCase();
setSearchValue(value);
if (searchTerm.trim() === "" && !selectedCategory) {
setCategoryAssets([]);
return;
}
if (selectedCategory === "Decals" || selectedSubCategory) {
const filteredModels = decalAsset?.filter((model: any) =>
model.decalName?.toLowerCase().includes(searchTerm.toLowerCase())
);
setCategoryAssets(filteredModels);
} else {
const filteredModels = filtereredAssets?.filter((model) => {
if (!model?.tags || !model?.filename || !model?.category) return false;
if (searchTerm.startsWith(":") && searchTerm.length > 1) {
const tagSearchTerm = searchTerm.slice(1);
return model.tags.toLowerCase().includes(tagSearchTerm);
} else if (selectedCategory) {
return (
model.category
.toLowerCase()
.includes(selectedCategory.toLowerCase()) &&
model.filename.toLowerCase().includes(searchTerm)
);
} else {
return model.filename.toLowerCase().includes(searchTerm);
}
});
setCategoryAssets(filteredModels);
}
};
useEffect(() => {
if (selectedCategory === "Decals") return;
const filteredAssets = async () => {
try {
const filt = await fetchAssets();
setFiltereredAssets(filt);
} catch {
echo.error("Filter asset not found");
}
};
filteredAssets();
}, [categoryAssets, selectedCategory]);
useEffect(() => {
if (
(searchValue.trim() === "" && selectedCategory === "Decals") ||
selectedSubCategory
) {
const filteredModels = decalAsset?.filter((model: any) =>
model.decalName?.toLowerCase().includes(searchValue.toLowerCase())
);
setCategoryAssets(filteredModels);
}
}, [selectedSubCategory, decalAsset, searchValue]);
useEffect(() => {
setCategoryList([
{ category: "Fenestration", categoryImage: feneration },
{ category: "Decals", categoryImage: decal },
{ category: "Vehicles", categoryImage: vehicle },
{ category: "Workstation", categoryImage: workStation },
{ category: "Machines", categoryImage: machines },
{ category: "Workers", categoryImage: worker },
{ category: "Storage", categoryImage: storage },
{ category: "Safety", categoryImage: safety },
{ category: "Office", categoryImage: office },
]);
}, []);
const fetchCategoryAssets = async (asset: any) => {
setisLoading(true);
setSelectedCategory(asset);
try {
const res = await getCategoryAsset(asset);
setCategoryAssets(res);
setFiltereredAssets(res);
setisLoading(false); // End loading
// eslint-disable-next-line
} catch (error) {
echo.error("failed to fetch assets");
setisLoading(false);
}
if (asset === "Decals") {
fetchCategoryDecals("Safety");
}
};
const fetchCategoryDecals = async (asset: any) => {
setisLoading(true);
// setSelectedCategory(asset);
try {
const res = await getCategoryDecals(asset);
setCategoryAssets(res);
setFiltereredAssets(res);
setDecalAsset(res);
setisLoading(false); // End loading
// eslint-disable-next-line
} catch (error) {
echo.error("failed to fetch assets");
setisLoading(false);
}
};
const activeSubcategories = [
{ name: "Safety", icon: <AlertIcon /> },
{ name: "Navigation", icon: <NavigationIcon /> },
{ name: "Branding", icon: <HangTagIcon /> },
{ name: "Informational", icon: <DecalInfoIcon /> },
];
return (
<div className="assets-container-main">
<Search onChange={handleSearchChange} value={searchValue} />
<div className="assets-list-section">
<section>
{(() => {
if (isLoading) {
return <SkeletonUI type="asset" />; // Show skeleton when loading
}
if (searchValue) {
return (
<div className="assets-result">
<div className="assets-wrapper">
<div className="searched-content">
<p>
Results for{" "}
<span className="search-for">'{searchValue}'</span>
</p>
</div>
<div className="assets-container">
{selectedCategory == "Decals" ? (
<>
<div className="catogory-asset-filter">
{activeSubcategories.map((cat, index) => (
<div
key={index}
className={`catogory-asset-filter-wrapper ${
selectedSubCategory === cat.name
? "active"
: ""
}`}
onClick={() => {
fetchCategoryDecals(cat.name);
setSelectedSubCategory(cat.name);
}}
>
<div className="sub-catagory">{cat.icon}</div>
<div className="sub-catagory">{cat.name}</div>
</div>
))}
</div>
{categoryAssets?.map((asset: any, index: number) => (
<div
key={`${index}-${asset}`}
className="assets"
id={asset.decalName}
title={asset.decalName}
>
<img
src={asset?.decalImage}
alt={asset.decalName}
className="asset-image"
onPointerDown={() => {
setSelectedItem({
name: asset.decalName,
id: asset.id,
type:
asset.type === "undefined"
? undefined
: asset.type,
category: asset.category,
// subType: asset.subType,
});
}}
/>
<div className="asset-name">
{asset.decalName
.split("_")
.map(
(word: any) =>
word.charAt(0).toUpperCase() +
word.slice(1)
)
.join(" ")}
</div>
</div>
))}
</>
) : (
categoryAssets?.map((asset: any, index: number) => (
<div
key={`${index}-${asset.filename}`}
className="assets"
id={asset.filename}
title={asset.filename}
>
<img
src={asset?.thumbnail}
alt={asset.filename}
className="asset-image"
onPointerDown={() => {
setSelectedItem({
name: asset.filename,
id: asset.AssetID,
type:
asset.type === "undefined"
? undefined
: asset.type,
});
}}
/>
<div className="asset-name">
{asset.filename
.split("_")
.map(
(word: any) =>
word.charAt(0).toUpperCase() + word.slice(1)
)
.join(" ")}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
if (selectedCategory) {
return (
<div className="assets-wrapper">
<h2 className="header">
{selectedCategory}
<button
className="back-button"
id="asset-backButtom"
onClick={() => {
setSelectedCategory(null);
setSelectedSubCategory(null);
setCategoryAssets([]);
}}
>
<div className="back-arrow">
<ArrowIcon />
</div>
Back
</button>
</h2>
{selectedCategory === "Decals" && (
<>
<div className="catogory-asset-filter">
{activeSubcategories.map((cat, index) => (
<div
key={index}
className={`catogory-asset-filter-wrapper ${
selectedSubCategory === cat.name ? "active" : ""
}`}
onClick={() => {
fetchCategoryDecals(cat.name);
setSelectedSubCategory(cat.name);
}}
>
<div className="sub-catagory">{cat.icon}</div>
<div className="sub-catagory">{cat.name}</div>
</div>
))}
</div>
</>
)}
{selectedCategory !== "Decals" && !selectedSubCategory ? (
<div className="assets-container">
{categoryAssets?.map((asset: any, index: number) => (
<div
key={`${index}-${asset}`}
className="assets"
id={asset.filename}
title={asset.filename}
>
<img
src={asset?.thumbnail}
alt={asset.filename}
className="asset-image"
onPointerDown={() => {
setSelectedItem({
name: asset.filename,
id: asset.AssetID,
type:
asset.type === "undefined"
? undefined
: asset.type,
category: asset.category,
subType: asset.subType,
});
}}
/>
<div className="asset-name">
{asset.filename
.split("_")
.map(
(word: any) =>
word.charAt(0).toUpperCase() + word.slice(1)
)
.join(" ")}
</div>
</div>
))}
{categoryAssets.length === 0 && (
<div className="no-asset">
🚧 The asset shelf is empty. We're working on filling
it up!
</div>
)}
</div>
) : (
<div className="assets-container">
{categoryAssets?.map((asset: any, index: number) => (
<div
key={`${index}-${asset}`}
className="assets"
id={asset.decalName}
title={asset.decalName}
>
<img
src={asset?.decalImage}
alt={asset.decalName}
className="asset-image"
onPointerDown={() => {
setDroppedDecal({
category: asset.category,
decalName: asset.decalName,
decalImage: asset.decalImage,
decalId: asset.id,
});
}}
/>
<div className="asset-name">
{asset.decalName
.split("_")
.map(
(word: any) =>
word.charAt(0).toUpperCase() + word.slice(1)
)
.join(" ")}
</div>
</div>
))}
{categoryAssets.length === 0 && (
<div className="no-asset">
🚧 The asset shelf is empty. We're working on filling
it up!
</div>
)}
</div>
)}
</div>
);
}
return (
<div className="assets-wrapper">
<h2 className="categories-header">Categories</h2>
<div className="categories-container">
{Array.from(
new Set(categoryList.map((asset) => asset.category))
).map((category, index) => {
const categoryInfo = categoryList.find(
(asset) => asset.category === category
);
return (
<div
key={`${index}-${category}`}
className="category"
id={category}
onClick={() => {
fetchCategoryAssets(category);
}}
>
<img
src={categoryInfo?.categoryImage ?? ""}
alt={category}
className="category-image"
draggable={false}
/>
<div className="category-name">{category}</div>
</div>
);
})}
</div>
</div>
);
})()}
</section>
</div>
</div>
);
};
export default Assets;

View File

@@ -3,7 +3,7 @@ import ToggleHeader from "../../ui/inputs/ToggleHeader";
import Outline from "./Outline";
import Header from "./Header";
import { useToggleStore } from "../../../store/useUIToggleStore";
import Assets from "./Assets";
import Assets from "./assetList/Assets";
import useModuleStore from "../../../store/useModuleStore";
import Widgets from "./visualization/widgets/Widgets";
import Templates from "../../../modules/visualization/template/Templates";

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useDecalStore } from "../../../../store/builder/store";
import { getFilteredAssets } from "./assetsHelpers/filteredAssetsHelper";
import { fetchCategoryDecals } from "./assetsHelpers/fetchDecalsHelper";
import {
fetchAllAssets,
fetchCategoryAssets,
} from "./assetsHelpers/fetchAssetsHelper";
import Search from "../../../ui/inputs/Search";
import SkeletonUI from "../../../templates/SkeletonUI";
import { RenderAsset } from "./assetsHelpers/renderAssetHelper";
import {
ACTIVE_DECAL_SUBCATEGORIES,
CATEGORY_LIST,
} from "./assetsHelpers/constants";
import { ArrowIcon } from "../../../icons/ExportCommonIcons";
const Assets: React.FC = () => {
const { selectedSubCategory, setSelectedSubCategory } = useDecalStore();
const [searchValue, setSearchValue] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetProp[] | DecalProp[]>([]);
const [globalResults, setGlobalResults] = useState<(AssetProp | DecalProp)[]>(
[]
);
const [isLoading, setIsLoading] = useState(false);
const filteredAssets = useMemo(
() =>
getFilteredAssets({
assets,
searchValue,
selectedCategory,
selectedSubCategory,
}),
[assets, searchValue, selectedCategory, selectedSubCategory]
);
const handleFetchCategory = useCallback(
async (category: string) => {
setIsLoading(true);
setSelectedCategory(category);
if (category === "Decals") {
const res = await fetchCategoryDecals("Safety");
setAssets(res);
setSelectedSubCategory("Safety");
} else {
const res = await fetchCategoryAssets(category);
setAssets(res);
}
setIsLoading(false);
},
[setSelectedSubCategory]
);
const fetchGlobalSearch = useCallback(async (term: string) => {
setIsLoading(true);
const allAssets = await fetchAllAssets();
const lowerTerm = term.toLowerCase();
const matches = allAssets.filter(
(a) =>
a.filename.toLowerCase().includes(lowerTerm) ||
a.tags?.toLowerCase().includes(lowerTerm) ||
a.category?.toLowerCase().includes(lowerTerm)
);
setGlobalResults(matches);
setIsLoading(false);
}, []);
useEffect(() => {
if (!selectedCategory && searchValue?.trim())
fetchGlobalSearch(searchValue);
else setGlobalResults([]);
}, [searchValue, selectedCategory, fetchGlobalSearch]);
return (
<div className="assets-container-main">
<Search onChange={setSearchValue} value={searchValue} />
<div className="assets-list-section">
<section>
{isLoading ? (
<SkeletonUI type="asset" />
) : searchValue || selectedCategory ? (
<div className="assets-wrapper">
{selectedCategory ? (
<>
<h2 className="header">
{selectedCategory}
<button
className="back-button"
onClick={() => {
setSelectedCategory(null);
setSelectedSubCategory(null);
setAssets([]);
setSearchValue(null);
}}
>
<div className="back-arrow">
<ArrowIcon />
</div>{" "}
Back
</button>
</h2>
{selectedCategory === "Decals" && (
<div className="catogory-asset-filter">
{ACTIVE_DECAL_SUBCATEGORIES.map((cat) => (
<div
key={cat.name}
className={`catogory-asset-filter-wrapper ${
selectedSubCategory === cat.name ? "active" : ""
}`}
onClick={async () => {
setIsLoading(true);
const res = await fetchCategoryDecals(cat.name);
setAssets(res);
setSelectedSubCategory(cat.name);
setIsLoading(false);
}}
>
<div className="sub-catagory">{cat.icon}</div>
<div className="sub-catagory">{cat.name}</div>
</div>
))}
</div>
)}
<div className="assets-container">
{filteredAssets.map((a, i) => (
<RenderAsset key={i} asset={a} index={i} />
))}
{filteredAssets.length === 0 && (
<div className="no-asset">🚧 No assets found</div>
)}
</div>
</>
) : (
<>
<h2 className="header">Global Search Results</h2>
<div className="assets-container">
{globalResults.map((a, i) => (
<RenderAsset key={i} asset={a} index={i} />
))}
{globalResults.length === 0 && (
<div className="no-asset">🔎 No matches found</div>
)}
</div>
</>
)}
</div>
) : (
<div className="assets-wrapper">
<h2 className="categories-header">Categories</h2>
<div className="categories-container">
{CATEGORY_LIST.map((cat) => (
<div
key={cat.category}
className="category"
onClick={() => handleFetchCategory(cat.category)}
>
<img
src={cat.categoryImage}
alt={cat.category}
className="category-image"
draggable={false}
/>
<div className="category-name">{cat.category}</div>
</div>
))}
</div>
</div>
)}
</section>
</div>
</div>
);
};
export default Assets;

View File

@@ -0,0 +1,34 @@
import vehicle from "../../../../../assets/image/categories/vehicles.png";
import workStation from "../../../../../assets/image/categories/workStation.png";
import machines from "../../../../../assets/image/categories/machines.png";
import worker from "../../../../../assets/image/categories/worker.png";
import storage from "../../../../../assets/image/categories/storage.png";
import office from "../../../../../assets/image/categories/office.png";
import safety from "../../../../../assets/image/categories/safety.png";
import feneration from "../../../../../assets/image/categories/feneration.png";
import decal from "../../../../../assets/image/categories/decal.png";
import {
AlertIcon,
DecalInfoIcon,
HangTagIcon,
NavigationIcon,
} from "../../../../icons/ExportCommonIcons";
export const CATEGORY_LIST: CategoryListProp[] = [
{ category: "Fenestration", categoryImage: feneration },
{ category: "Decals", categoryImage: decal },
{ category: "Vehicles", categoryImage: vehicle },
{ category: "Workstation", categoryImage: workStation },
{ category: "Machines", categoryImage: machines },
{ category: "Workers", categoryImage: worker },
{ category: "Storage", categoryImage: storage },
{ category: "Safety", categoryImage: safety },
{ category: "Office", categoryImage: office },
];
export const ACTIVE_DECAL_SUBCATEGORIES = [
{ name: "Safety", icon: <AlertIcon /> },
{ name: "Navigation", icon: <NavigationIcon /> },
{ name: "Branding", icon: <HangTagIcon /> },
{ name: "Informational", icon: <DecalInfoIcon /> },
];

View File

@@ -0,0 +1,22 @@
import { getCategoryAsset } from "../../../../../services/factoryBuilder/asset/assets/getCategoryAsset";
import { fetchAssets } from "../../../../../services/marketplace/fetchAssets";
export const fetchCategoryAssets = async (category: string): Promise<AssetProp[]> => {
if (category === "Decals") return []; // handled separately
try {
const res = await getCategoryAsset(category);
return res;
} catch (err) {
console.error("Failed to fetch category assets", err);
return [];
}
};
export const fetchAllAssets = async (): Promise<AssetProp[]> => {
try {
return await fetchAssets();
} catch (err) {
console.error("Failed to fetch all assets", err);
return [];
}
};

View File

@@ -0,0 +1,11 @@
import { getCategoryDecals } from "../../../../../services/factoryBuilder/asset/decals/getCategoryDecals";
export const fetchCategoryDecals = async (subcategory: string): Promise<DecalProp[]> => {
try {
const res = await getCategoryDecals(subcategory);
return res;
} catch (err) {
console.error("Failed to fetch decals", err);
return [];
}
};

View File

@@ -0,0 +1,34 @@
interface FilterProps {
assets: AssetProp[] | DecalProp[];
searchValue: string | null;
selectedCategory: string | null;
selectedSubCategory: string | null;
}
export const getFilteredAssets = ({
assets,
searchValue,
selectedCategory,
selectedSubCategory,
}: FilterProps) => {
const term = searchValue?.trim().toLowerCase();
if (!term) return assets;
if (selectedCategory === "Decals" || selectedSubCategory) {
return (assets as DecalProp[]).filter((a) =>
a.decalName?.toLowerCase().includes(term)
);
}
return (assets as AssetProp[]).filter((a) => {
const tags = a.tags?.toLowerCase() ?? "";
const filename = a.filename?.toLowerCase() ?? "";
const category = a.category?.toLowerCase() ?? "";
if (term.startsWith(":")) return tags.includes(term.slice(1));
if (selectedCategory)
return category.includes(selectedCategory.toLowerCase()) && filename.includes(term);
return filename.includes(term);
});
};

View File

@@ -0,0 +1,58 @@
import React from "react";
import { useDroppedDecal, useSelectedItem } from "../../../../../store/builder/store";
export const RenderAsset: React.FC<{ asset: AssetProp | DecalProp; index: number }> = ({ asset, index }) => {
const { setSelectedItem } = useSelectedItem();
const { setDroppedDecal } = useDroppedDecal();
if ("decalName" in asset) {
return (
<div key={`${index}-${asset.decalName}`} className="assets" id={asset.decalName} title={asset.decalName}>
<img
src={asset.decalImage}
alt={asset.decalName}
className="asset-image"
onPointerDown={() =>
setDroppedDecal({
category: asset.category,
decalName: asset.decalName,
decalImage: asset.decalImage,
decalId: asset.id,
})
}
/>
<div className="asset-name">
{asset.decalName
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</div>
</div>
);
}
return (
<div key={`${index}-${asset.filename}`} className="assets" id={asset.filename} title={asset.filename}>
<img
src={asset.thumbnail}
alt={asset.filename}
className="asset-image"
onPointerDown={() =>
setSelectedItem({
name: asset.filename,
id: asset.AssetID,
type: asset.type === "undefined" ? undefined : asset.type,
category: asset.category,
subType: asset.subType,
})
}
/>
<div className="asset-name">
{asset.filename
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")}
</div>
</div>
);
};

View File

@@ -1,8 +1,8 @@
import React, { ChangeEvent, useState } from "react";
import React, { ChangeEvent, useEffect, useState } from "react";
import { CloseIcon, SearchIcon } from "../../icons/ExportCommonIcons";
interface SearchProps {
value?: string; // The current value of the search input
value?: string | null; // The current value of the search input
placeholder?: string; // Placeholder text for the input
onChange: (value: string) => void; // Callback function to handle input changes
}
@@ -22,7 +22,15 @@ const Search: React.FC<SearchProps> = ({
onChange(newValue); // Call the onChange prop with the new value
};
useEffect(() => {
if (value === null) {
setInputValue("");
handleBlur();
}
}, [value]);
const handleClear = () => {
echo.warn("Search field cleared.");
setInputValue("");
onChange(""); // Clear the input value
};
@@ -48,7 +56,7 @@ const Search: React.FC<SearchProps> = ({
<input
type="text"
className="search-input"
value={inputValue}
value={inputValue ?? ""}
placeholder={placeholder}
onChange={handleInputChange}
onFocus={handleFocus}

View File

@@ -5,6 +5,11 @@ import type { CameraControls } from "@react-three/drei";
const CameraShortcutsControls = () => {
const { camera, controls } = useThree();
const isTextInput = (element: Element | null): boolean =>
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element?.getAttribute("contenteditable") === "true";
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -21,6 +26,8 @@ const CameraShortcutsControls = () => {
const dir = new THREE.Vector3().subVectors(camera.position, target).normalize();
if (isTextInput(document.activeElement)) return;
switch (e.key) {
case "1": // Front
pos = new THREE.Vector3(0, 0, distance).add(target);

29
app/src/types/uiTypes.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
interface AssetProp {
filename: string;
thumbnail?: string;
category: string;
description?: string;
tags: string;
url?: string;
uploadDate?: number;
isArchieve?: boolean;
animated?: boolean;
price?: number;
CreatedBy?: string;
AssetID?: string;
type?: string;
subType?: string;
}
interface DecalProp {
id: string;
decalName: string;
decalImage: string;
category: string;
type?: string;
}
interface CategoryListProp {
categoryImage: string;
category: string;
}