feature/search objects (#575)

- add functions in API manager to use the search API
- adds search field to object tab, it searches through featured/mine objects depending on which tab you are on
- this feature is hid behind a feature flag until we have a proper design and solution to handling assets


---------

Co-authored-by: wil <wil@storyteller.ai>
This commit is contained in:
BFlatandwil authored and GitHub committed 2024-07-03 16:12:14 -04:00
1 parent 403f0b7990
commit 0c8ce2ced7
21 files changed
+371 -67

No files matched your search

+1 -1
View File
@@ -6,4 +6,4 @@ GRAVATAR_API="https://www.gravatar.com"
DEPLOY_PRIME_URL="https://studio-staging.studio.storyteller.ai"
REACT_APP_PUBLIC_POSTHOG_KEY="phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb"
REACT_APP_PUBLIC_POSTHOG_UI="https://us.i.posthog.com"
CONTEXT="PRODUCTION"
CONTEXT="DEVELOPMENT"
+14 -7
View File
@@ -90,13 +90,20 @@ Add the feature flag for all users.
To check the feature flag in the app use code similar to this
```ts
const [myFlag, setMyFlag] = useState(false);
useEffect(() => {
const flag = posthog.isFeatureEnabled("<flag name>");
if (flag) {
setMyFlag(true);
}
}, []);
import { usePosthogFeatureFlag } from "~/hooks/usePosthogFeatureflag";
import { FeatureFlags } from "~/enums";
const Component = () => {
const flag = usePosthogFeatureFlag(FeaturesFlags.FLAG_NAME);
// if flag is true, the feature shoud be executed & showed;
// if flag is false, the feature should be disabled & hid;
// for example
return(
<>
{flag && <ComponentThatDependsOnTheFlag />}
</>
);
};
```
## Other Documentation
+71
View File
@@ -34,6 +34,13 @@ interface ListUserMediaQuery {
filter_engine_categories?: FilterEngineCategories[];
}
interface SearchFeaturedMediaQuery {
search_term: string;
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
}
export class MediaFilesApi extends ApiManager {
public async DeleteMediaFileByToken({
mediaFileToken,
@@ -215,6 +222,70 @@ export class MediaFilesApi extends ApiManager {
});
}
public async SearchFeaturedMediaFiles(
query: SearchFeaturedMediaQuery,
): Promise<ApiResponse<MediaInfo[], Pagination>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/search_featured`;
const queryWithStrings = {
search_term: query.search_term,
filter_media_classes: query.filter_media_classes
? query.filter_media_classes.join(",")
: undefined,
filter_media_type: query.filter_media_type
? query.filter_media_type.join(",")
: undefined,
filter_engine_categories: query.filter_engine_categories
? query.filter_engine_categories.join(",")
: undefined,
};
return await this.get<{
success: boolean;
results: MediaInfo[];
pagination: Pagination;
}>({ endpoint, query: queryWithStrings })
.then((response) => ({
success: true,
data: response.results,
pagination: response.pagination,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async SearchUserMediaFiles(
query: SearchFeaturedMediaQuery,
): Promise<ApiResponse<MediaInfo[], Pagination>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/search_session`;
const queryWithStrings = {
search_term: query.search_term,
filter_media_classes: query.filter_media_classes
? query.filter_media_classes.join(",")
: undefined,
filter_media_type: query.filter_media_type
? query.filter_media_type.join(",")
: undefined,
filter_engine_categories: query.filter_engine_categories
? query.filter_engine_categories.join(",")
: undefined,
};
return await this.get<{
success: boolean;
results: MediaInfo[];
pagination: Pagination;
}>({ endpoint, query: queryWithStrings })
.then((response) => ({
success: true,
data: response.results,
pagination: response.pagination,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async RenameMediaFileByToken({
mediaToken,
name,
+3 -1
View File
@@ -12,6 +12,7 @@ import {
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputClassName?: string;
iconClassName?: string;
label?: string;
icon?: IconDefinition;
isError?: boolean;
@@ -24,6 +25,7 @@ export const Input = React.forwardRef(
label,
icon,
inputClassName,
iconClassName,
className,
id,
isError,
@@ -42,7 +44,7 @@ export const Input = React.forwardRef(
{icon && (
<FontAwesomeIcon
icon={icon}
className="text-md absolute pl-3 pt-3"
className={twMerge("text-md absolute pl-3 pt-3", iconClassName)}
/>
)}
<input
@@ -0,0 +1,88 @@
import {
faCircleXmark,
faFilterList,
faSearch,
} from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, Tooltip } from "~/components";
import debounce from "lodash/debounce";
import { useRef, useState, useEffect, useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface SearchFilterProps {
onSearchChange: (value: string) => void;
placeholder?: string;
searchTerm?: string;
showFilters?: boolean;
}
export const SearchFilter = ({
onSearchChange,
placeholder = "Search...",
searchTerm = "",
showFilters = false,
}: SearchFilterProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(searchTerm);
const [hasText, setHasText] = useState(searchTerm.length > 0);
useEffect(() => {
setInputValue(searchTerm);
setHasText(searchTerm.length > 0);
}, [searchTerm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedChangeHandler = useCallback(
debounce((value: string) => {
onSearchChange(value);
}, 500),
[],
);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValue(value);
setHasText(value.length > 0);
debouncedChangeHandler(value);
};
const clearSearch = () => {
setInputValue("");
setHasText(false);
onSearchChange("");
};
return (
<div className="flex gap-2">
<div className="relative grow">
<Input
ref={inputRef}
icon={faSearch}
iconClassName="opacity-50 pt-[11px] text-sm"
inputClassName="h-9 rounded-lg text-sm bg-ui-controls-button/30 pr-8 pl-9"
placeholder={placeholder}
className="grow"
value={inputValue}
onChange={handleInputChange}
/>
{hasText && (
<FontAwesomeIcon
icon={faCircleXmark}
className="absolute right-2.5 top-1/2 -translate-y-1/2 transform cursor-pointer opacity-50 transition-all duration-100 hover:opacity-100"
onClick={clearSearch}
/>
)}
</div>
{showFilters && (
<Tooltip position="top" content="Filters">
<Button
icon={faFilterList}
variant="secondary"
className="h-9 w-9"
iconClassName="text-[16px]"
></Button>
</Tooltip>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from "./SearchFilter";
+1
View File
@@ -17,6 +17,7 @@ export * from "./LoadingBar";
export * from "./NumberInput";
export * from "./Pagination";
export * from "./Pill";
export * from "./SearchFilter";
export * from "./Select"; //Select's onChange type should be generic
export * from "./Slider";
export * from "./Tabs";
+5 -2
View File
@@ -1,7 +1,8 @@
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode, useEffect } from "react";
import { hydrateRoot } from "react-dom/client";
import posthog from "posthog-js";
import { posthog } from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
function PosthogInit() {
@@ -22,7 +23,9 @@ startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
<PostHogProvider client={posthog}>
<RemixBrowser />
</PostHogProvider>
<PosthogInit />
</StrictMode>,
);
+3
View File
@@ -0,0 +1,3 @@
export enum FeatureFlags {
SHOW_SEARCH_OBJECTS = "show-search-objects",
}
+1
View File
@@ -5,6 +5,7 @@ export * from "./authentication";
export * from "./Billing";
export * from "./ClipGroup";
export * from "./ClipType";
export * from "./FeatureFlags";
export * from "./FileTypes";
export * from "./JobStatus";
export * from "./MediaFileAnimationTypes";
-1
View File
@@ -1 +0,0 @@
export * from "./useActiveJobs";
+1
View File
@@ -0,0 +1 @@
export * from "./usePosthogFeatureFlag";
@@ -0,0 +1,11 @@
import { useFeatureFlagEnabled } from "posthog-js/react";
import { FeatureFlags } from "~/enums/FeatureFlags";
import environmentVariables from "~/Classes/EnvironmentVariables";
export const usePosthogFeatureFlag = (flag: FeatureFlags): boolean => {
const enabled = useFeatureFlagEnabled(flag) ?? false;
if (environmentVariables.values.CONTEXT !== "PRODUCTION") {
return true;
}
return enabled;
};
+2 -2
View File
@@ -2,11 +2,11 @@ import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { TrackProvider } from "~/pages/PageEnigma/contexts/TrackContext/TrackProvider";
import { DragComponent } from "~/pages/PageEnigma/comps/DragComponent/DragComponent";
import { EngineProvider } from "./contexts/EngineContext";
import { useActiveJobs } from "~/hooks";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { useQueueHandler } from "./hooks/useQueueHandler";
import { ErrorDialog, LoadingDots } from "~/components";
import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { EditorLoadingBar } from "./comps/EditorLoadingBar";
import { useSignals } from "@preact/signals-react/runtime";
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { faCirclePlus, faMobileNotch } from "@fortawesome/pro-solid-svg-icons";
import { faCirclePlus } from "@fortawesome/pro-solid-svg-icons";
import { MediaInfo, MediaItem } from "~/pages/PageEnigma/models";
import {
@@ -14,7 +14,6 @@ import {
import { ItemElements } from "~/pages/PageEnigma/comps/SidePanelTabs/itemTabs/ItemElements";
import {
Button,
ButtonDialogue,
FilterButtons,
Pagination,
UploadModalMovement,
@@ -131,27 +130,6 @@ export const ExpressionTab = () => {
>
Upload Expression
</Button>
<ButtonDialogue
buttonProps={{
variant: "secondary",
label:
"Watch a tutorial on how to create expressions using your iPhone",
className:
"justify-center border-2 border-white/30 hover:border-white/40 text-start p-3 gap-3.5 rounded-xl",
icon: faMobileNotch,
iconClassName: "text-3xl",
}}
dialogProps={{
className: "max-w-6xl",
}}
title={<>Video Tutorial: Creating your own expressions</>}
>
<video
className="aspect-video w-full rounded-lg"
controls
src="https://storage.googleapis.com/vocodes-public/media/t/p/w/q/6/tpwq6beqn95f1h1q9e88c42r0gdekg7p/storyteller_tpwq6beqn95f1h1q9e88c42r0gdekg7p.mp4"
></video>
</ButtonDialogue>
</div>
<div className="h-full w-full overflow-y-auto px-4 pb-4">
<ItemElements
@@ -2,6 +2,8 @@ import { ItemElement } from "./ItemElement";
import { MediaItem } from "~/pages/PageEnigma/models";
import { dndSidePanelWidth, sidePanelWidth } from "~/pages/PageEnigma/signals";
import { H4, P, LoadingDots } from "~/components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEmptySet } from "@fortawesome/pro-solid-svg-icons";
interface Props {
busy?: boolean;
@@ -51,11 +53,15 @@ export const ItemElements = ({
</div>
);
}
if (items.length === 0) {
if (items.length === 0 && !busy) {
return (
<div className="h-full w-full text-center">
<H4> You do not have anything here. </H4>
<P> Plase upload some assets. </P>
<div className="flex h-full w-full flex-col items-center justify-center text-center">
<FontAwesomeIcon
icon={faEmptySet}
className="mb-4 text-4xl opacity-30"
/>
<H4>You do not have anything here.</H4>
<P className="text-sm opacity-75">Please upload some assets.</P>
</div>
);
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { usePosthogFeatureFlag } from "~/hooks/usePosthogFeatureFlag";
import { useSignals } from "@preact/signals-react/runtime";
import { faCirclePlus } from "@fortawesome/pro-solid-svg-icons";
@@ -6,6 +7,7 @@ import { faCirclePlus } from "@fortawesome/pro-solid-svg-icons";
import {
AssetFilterOption,
AssetType,
FeatureFlags,
FilterEngineCategories,
ToastTypes,
} from "~/enums";
@@ -16,7 +18,13 @@ import { shapeItems } from "~/pages/PageEnigma/signals";
import { BucketConfig } from "~/api/BucketConfig";
import { ItemElements } from "~/pages/PageEnigma/comps/SidePanelTabs/itemTabs/ItemElements";
import { Button, FileWrapper, FilterButtons, Pagination } from "~/components";
import {
Button,
FileWrapper,
FilterButtons,
SearchFilter,
Pagination,
} from "~/components";
import { TabTitle } from "~/pages/PageEnigma/comps/SidePanelTabs/comps/TabTitle";
import { MediaFilesApi } from "~/Classes/ApiManager";
import { addToast } from "~/signals";
@@ -24,6 +32,17 @@ import { addToast } from "~/signals";
export const ObjectsTab = () => {
useSignals();
const showSearchObjectComponent = usePosthogFeatureFlag(
FeatureFlags.SHOW_SEARCH_OBJECTS,
);
const [searchTermFeatured, setSearchTermFeatured] = useState("");
const [searchTermMine, setSearchTermMine] = useState("");
const [filteredSearchObjectsFeatured, setFilteredSearchObjectsFeatured] =
useState<MediaItem[]>([]);
const [filteredSearchObjectsMine, setFilteredSearchObjectsMine] = useState<
MediaItem[]
>([]);
const [userObjects, setUserObjects] = useState<MediaItem[] | undefined>(
undefined,
);
@@ -46,12 +65,15 @@ export const ObjectsTab = () => {
const [fetchStatuses, setFetchStatuses] = useState({
userObjectsFetch: FetchStatus.READY,
featuredObjectsFetch: FetchStatus.READY,
searchFetch: FetchStatus.READY,
});
const isFetching =
fetchStatuses.userObjectsFetch === FetchStatus.READY ||
fetchStatuses.userObjectsFetch === FetchStatus.IN_PROGRESS ||
fetchStatuses.featuredObjectsFetch === FetchStatus.READY ||
fetchStatuses.featuredObjectsFetch === FetchStatus.IN_PROGRESS;
fetchStatuses.featuredObjectsFetch === FetchStatus.IN_PROGRESS ||
fetchStatuses.searchFetch === FetchStatus.READY ||
fetchStatuses.searchFetch === FetchStatus.IN_PROGRESS;
const responseMapping = (data: MediaInfo[]) => {
return data.map((item) => {
@@ -78,6 +100,86 @@ export const ObjectsTab = () => {
});
};
const fetchFeaturedSearchResults = useCallback(async () => {
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.IN_PROGRESS,
}));
const searchTerm = searchTermFeatured;
if (!searchTerm.trim()) {
setFilteredSearchObjectsFeatured([]);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.SUCCESS,
}));
return;
}
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.SearchFeaturedMediaFiles({
search_term: searchTerm,
filter_engine_categories: [FilterEngineCategories.OBJECT],
});
if (response.success && response.data) {
const newSearchObjects = responseMapping(response.data);
setFilteredSearchObjectsFeatured(newSearchObjects);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.SUCCESS,
}));
} else {
addToast(
ToastTypes.ERROR,
response.errorMessage || "Failed to fetch search results",
);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.ERROR,
}));
}
}, [searchTermFeatured]);
const fetchUserSearchResults = useCallback(async () => {
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.IN_PROGRESS,
}));
const searchTerm = searchTermMine;
if (!searchTerm.trim()) {
setFilteredSearchObjectsMine([]);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.SUCCESS,
}));
return;
}
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.SearchUserMediaFiles({
search_term: searchTerm,
filter_engine_categories: [FilterEngineCategories.OBJECT],
});
if (response.success && response.data) {
const newSearchObjects = responseMapping(response.data);
setFilteredSearchObjectsMine(newSearchObjects);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.SUCCESS,
}));
} else {
addToast(
ToastTypes.ERROR,
response.errorMessage || "Failed to fetch search results",
);
setFetchStatuses((curr) => ({
...curr,
searchFetch: FetchStatus.ERROR,
}));
}
}, [searchTermMine]);
const fetchUserObjects = useCallback(async () => {
setFetchStatuses((curr) => ({
...curr,
@@ -86,7 +188,10 @@ export const ObjectsTab = () => {
const mediaFilesApi = new MediaFilesApi();
const response = await mediaFilesApi.ListUserMediaFiles({
page_size: 100,
filter_engine_categories: [FilterEngineCategories.OBJECT, FilterEngineCategories.IMAGE_PLANE],
filter_engine_categories: [
FilterEngineCategories.OBJECT,
FilterEngineCategories.IMAGE_PLANE,
],
});
if (response.success && response.data) {
@@ -145,6 +250,20 @@ export const ObjectsTab = () => {
}
}, [userObjects, fetchUserObjects, featuredObjects, fetchFeaturedObjects]);
useEffect(() => {
if (selectedFilter === AssetFilterOption.FEATURED) {
fetchFeaturedSearchResults();
} else if (selectedFilter === AssetFilterOption.MINE) {
fetchUserSearchResults();
}
}, [
searchTermFeatured,
searchTermMine,
fetchFeaturedSearchResults,
fetchUserSearchResults,
selectedFilter,
]);
return (
<FileWrapper
onSuccess={fetchUserObjects}
@@ -158,7 +277,7 @@ export const ObjectsTab = () => {
setSelectedFilter(Number(button));
}}
/>
<div className="w-full px-4">
<div className="flex w-full flex-col gap-3 px-4">
<Button
className="file-picker-button py-3"
htmlFor={parentId}
@@ -167,6 +286,21 @@ export const ObjectsTab = () => {
>
Upload Object
</Button>
{showSearchObjectComponent && (
<SearchFilter
searchTerm={
selectedFilter === AssetFilterOption.FEATURED
? searchTermFeatured
: searchTermMine
}
onSearchChange={
selectedFilter === AssetFilterOption.FEATURED
? setSearchTermFeatured
: setSearchTermMine
}
key={selectedFilter}
/>
)}
</div>
<div className="w-full grow overflow-y-auto rounded px-4 pb-4">
<ItemElements
@@ -174,7 +308,15 @@ export const ObjectsTab = () => {
debug="objects tab"
currentPage={currentPage}
pageSize={pageSize}
items={filteredObjects}
items={
selectedFilter === AssetFilterOption.FEATURED
? searchTermFeatured
? filteredSearchObjectsFeatured
: filteredObjects
: searchTermMine
? filteredSearchObjectsMine
: filteredObjects
}
/>
</div>
{totalPages > 1 && (
@@ -188,7 +330,7 @@ export const ObjectsTab = () => {
/>
)}
</>
)} // End FileWrapper Render
)}
/>
);
};
-12
View File
@@ -55,18 +55,6 @@ export const links: LinksFunction = () => [
];
export async function loader() {
// if (process.env.NODE_ENV === "development") {
// const env = {
// BASE_API: "",
// GOOGLE_API: "",
// // @ts-expect-error ProvessEnv is correct
// FUNNEL_API: process.env.FUNNEL_API || "%BUILD_FUNNEL_API%",
// // @ts-expect-error ProvessEnv is correct
// CDN_API: process.env.CDN_API || "%BUILD_CDN_API%",
// GRAVATAR_API: "",
// } as Record<string, string | boolean>;
// return { ENV: env };
// }
const env = {
// @ts-expect-error ProvessEnv is correct
BASE_API: process.env.BASE_API || "%BUILD_BASE_API%",
+1 -1
View File
@@ -4,7 +4,7 @@
@layer components {
.filter-tab {
@apply text-nowrap rounded-lg border-2 border-assets-background bg-assets-background px-2.5 py-1.5 text-sm font-medium text-white/80 transition-all duration-150 hover:border-assets-background/0 hover:bg-assets-background/80;
@apply text-nowrap rounded-lg border-2 border-assets-background bg-assets-background px-2.5 py-1 text-[13px] font-medium text-white/80 transition-all duration-150 hover:border-assets-background/0 hover:bg-assets-background/80;
}
.filter-tab.active {
+6 -6
View File
@@ -25,6 +25,7 @@
"@remix-run/router": "^1.15.3",
"@remix-run/serve": "^2.8.1",
"@tweenjs/tween.js": "^18.6.4",
"@types/lodash": "^4.17.6",
"ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
"common": "file:common",
"csstype": "^3.1.3",
@@ -33,6 +34,7 @@
"detect-gpu": "^5.0.38",
"http-proxy-middleware": "^3.0.0",
"isbot": "^4.1.0",
"lodash.debounce": "^4.0.8",
"posthog-js": "^1.141.0",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
@@ -7450,10 +7452,9 @@
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.17.5",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.5.tgz",
"integrity": "sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw==",
"dev": true
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz",
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA=="
},
"node_modules/@types/mdast": {
"version": "3.0.15",
@@ -15874,8 +15875,7 @@
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"dev": true
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
+3 -1
View File
@@ -14,7 +14,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "npx chromatic --project-token=chpt_0945f90d5d3626c",
"prepare": "husky install",
"prepare": "husky",
"test": "jest"
},
"dependencies": {
@@ -37,6 +37,7 @@
"@remix-run/router": "^1.15.3",
"@remix-run/serve": "^2.8.1",
"@tweenjs/tween.js": "^18.6.4",
"@types/lodash": "^4.17.6",
"ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
"common": "file:common",
"csstype": "^3.1.3",
@@ -45,6 +46,7 @@
"detect-gpu": "^5.0.38",
"http-proxy-middleware": "^3.0.0",
"isbot": "^4.1.0",
"lodash.debounce": "^4.0.8",
"posthog-js": "^1.141.0",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",