mirror of
https://github.com/filecoin-project/slate.git
synced 2024-12-26 18:44:56 +03:00
feat(GlobalCarousel/Jumpers): add EditSlates jumper
This commit is contained in:
parent
fc7d37a2f5
commit
c4631b4d18
@ -1,523 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as System from "~/components/system";
|
||||
import * as Jumper from "~/components/system/components/fragments/Jumper";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as UserBehaviors from "~/common/user-behaviors";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as MobileJumper from "~/components/system/components/fragments/MobileJumper";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as RovingTabIndex from "~/components/core/RovingTabIndex";
|
||||
|
||||
import { Show } from "~/components/utility/Show";
|
||||
import { css } from "@emotion/react";
|
||||
import { AnimateSharedLayout, motion } from "framer-motion";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { useEventListener } from "~/common/hooks";
|
||||
|
||||
const STYLES_CHANNEL_BUTTON = (theme) => css`
|
||||
position: relative;
|
||||
padding: 5px 12px 7px;
|
||||
border: 1px solid ${theme.semantic.borderGrayLight4};
|
||||
border-radius: 12px;
|
||||
color: ${theme.semantic.textBlack};
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3 ease-in-out;
|
||||
`;
|
||||
|
||||
const STYLES_CHANNEL_BUTTON_SELECTED = (theme) => css`
|
||||
background-color: ${theme.semantic.bgGrayLight4};
|
||||
`;
|
||||
|
||||
const ChannelButton = React.forwardRef(({ children, isSelected, css, ...props }, ref) => {
|
||||
return (
|
||||
<System.ButtonPrimitive
|
||||
{...props}
|
||||
ref={ref}
|
||||
css={[STYLES_CHANNEL_BUTTON, isSelected && STYLES_CHANNEL_BUTTON_SELECTED, css]}
|
||||
>
|
||||
<System.P2 nbrOflines={1} as="span">
|
||||
{children}
|
||||
</System.P2>
|
||||
</System.ButtonPrimitive>
|
||||
);
|
||||
});
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STYLES_RETURN_KEY = (theme) => css`
|
||||
padding: 0px 2px;
|
||||
border-radius: 6px;
|
||||
background-color: ${theme.semantic.bgGrayLight};
|
||||
`;
|
||||
|
||||
function ChannelKeyboardShortcut({ searchResults, searchQuery, onAddFileToChannel }) {
|
||||
const [isFileAdded, setIsFileAdded] = React.useState(false);
|
||||
React.useLayoutEffect(() => {
|
||||
if (isFileAdded) {
|
||||
setIsFileAdded(false);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const { publicChannels, privateChannels } = searchResults;
|
||||
const selectedChannel = [...publicChannels, ...privateChannels][0];
|
||||
|
||||
useEventListener({
|
||||
type: "keydown",
|
||||
handler: (e) => {
|
||||
if (e.key === "Enter") {
|
||||
onAddFileToChannel(selectedChannel, selectedChannel.doesContainFile);
|
||||
setIsFileAdded(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// NOTE(amine): don't show the 'select channel ⏎' hint when the channel is created optimistically
|
||||
if (isFileAdded || !selectedChannel?.ownerId) return null;
|
||||
|
||||
return (
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<System.P3 color="textGray" style={{ display: "inline-flex" }}>
|
||||
Select {selectedChannel.isPublic ? "public" : "private"} tag "
|
||||
<System.P3 nbrOflines={1} as="span" style={{ maxWidth: 100 }}>
|
||||
{selectedChannel.slatename}
|
||||
</System.P3>
|
||||
"
|
||||
</System.P3>
|
||||
<System.P3 css={STYLES_RETURN_KEY} style={{ marginLeft: 4 }}>
|
||||
⏎
|
||||
</System.P3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STYLES_SEARCH_TAGS_INPUT = (theme) => css`
|
||||
background-color: transparent;
|
||||
${theme.semantic.textGray};
|
||||
box-shadow: none;
|
||||
height: 52px;
|
||||
padding: 0px;
|
||||
::placeholder {
|
||||
color: ${theme.semantic.textGray};
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_SEARCH_TAGS_INPUT_WRAPPER = (theme) => css`
|
||||
color: ${theme.semantic.textGray};
|
||||
width: 100%;
|
||||
margin: 1px;
|
||||
`;
|
||||
|
||||
function ChannelInput({ value, searchResults, onChange, onAddFileToChannel, ...props }) {
|
||||
const { publicChannels, privateChannels } = searchResults;
|
||||
const [isShortcutVisible, setShortcutVisibility] = React.useState();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value && publicChannels.length + privateChannels.length === 1) {
|
||||
setShortcutVisibility(true);
|
||||
} else {
|
||||
setShortcutVisibility(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div css={[STYLES_SEARCH_TAGS_INPUT_WRAPPER, Styles.CONTAINER_CENTERED]}>
|
||||
<SVG.Hash width={16} />
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<System.Input
|
||||
full
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
name="search"
|
||||
placeholder="Search or create a new tag"
|
||||
inputCss={STYLES_SEARCH_TAGS_INPUT}
|
||||
{...props}
|
||||
/>
|
||||
<div style={{ position: "absolute", top: "50%", transform: "translateY(-50%)", right: 20 }}>
|
||||
{isShortcutVisible ? (
|
||||
<ChannelKeyboardShortcut
|
||||
searchQuery={value}
|
||||
searchResults={searchResults}
|
||||
onAddFileToChannel={onAddFileToChannel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
const STYLES_TAG = (theme) => css`
|
||||
padding: 7px 12px 9px;
|
||||
border-radius: 12px;
|
||||
background-color: ${theme.semantic.bgGrayLight4};
|
||||
`;
|
||||
|
||||
function ChannelsEmpty() {
|
||||
return (
|
||||
<div css={Styles.VERTICAL_CONTAINER_CENTERED}>
|
||||
<System.P2 color="textGrayDark" style={{ textAlign: "center" }}>
|
||||
You don’t have any tags yet. <br /> Start typing above to create one.
|
||||
</System.P2>
|
||||
<div css={STYLES_TAG} style={{ marginTop: 19 }}>
|
||||
<SVG.Hash width={16} height={16} style={{ display: "block" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STYLES_CHANNEL_BUTTONS_WRAPPER = css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: calc(-8px + 6px) 0 0 -8px;
|
||||
width: calc(100% + 8px);
|
||||
|
||||
& > * {
|
||||
margin: 8px 0 0 8px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
function Channels({
|
||||
header,
|
||||
isPublic,
|
||||
|
||||
searchQuery,
|
||||
|
||||
channels,
|
||||
isCreatingChannel,
|
||||
|
||||
onAddFileToChannel,
|
||||
onCreateChannel,
|
||||
}) {
|
||||
const showChannel = !isCreatingChannel && channels.length === 0;
|
||||
|
||||
return !showChannel ? (
|
||||
<div>
|
||||
<System.H6 as="h2" color="textGray">
|
||||
{isCreatingChannel ? `Create ${header.toLowerCase()} tag` : header}
|
||||
</System.H6>
|
||||
|
||||
<Show when={isCreatingChannel && isPublic}>
|
||||
<System.P3 color="textGray" style={{ marginTop: 2 }}>
|
||||
Objects with a public tag will show up on your public profile.
|
||||
</System.P3>
|
||||
</Show>
|
||||
|
||||
<AnimateSharedLayout>
|
||||
<RovingTabIndex.Provider axis="horizontal">
|
||||
<RovingTabIndex.List css={STYLES_CHANNEL_BUTTONS_WRAPPER}>
|
||||
{channels.map((channel, index) => (
|
||||
<motion.div layoutId={`jumper-${channel.id}`} initial={false} key={channel.id}>
|
||||
<RovingTabIndex.Item index={index}>
|
||||
<ChannelButton
|
||||
isSelected={channel.doesContainFile}
|
||||
onClick={() => onAddFileToChannel(channel, channel.doesContainFile)}
|
||||
title={channel.slatename}
|
||||
style={{ maxWidth: "48ch" }}
|
||||
>
|
||||
{channel.slatename}
|
||||
</ChannelButton>
|
||||
</RovingTabIndex.Item>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<Show when={isCreatingChannel}>
|
||||
<motion.div initial={{ opacity: 0.5, y: 4 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<RovingTabIndex.Item index={channels.length}>
|
||||
<ChannelButton
|
||||
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
|
||||
onClick={(e) => (e.stopPropagation(), onCreateChannel(searchQuery))}
|
||||
title={searchQuery}
|
||||
>
|
||||
<SVG.Plus
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
position: "relative",
|
||||
top: -1,
|
||||
verticalAlign: "middle",
|
||||
pointerEvents: "none",
|
||||
display: "inline",
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: 4 }}>{searchQuery}</span>
|
||||
</ChannelButton>
|
||||
</RovingTabIndex.Item>
|
||||
</motion.div>
|
||||
</Show>
|
||||
</RovingTabIndex.List>
|
||||
</RovingTabIndex.Provider>
|
||||
</AnimateSharedLayout>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const useChannels = ({ viewer, file }) => {
|
||||
const [channels, setChannels] = React.useState(viewer.slates);
|
||||
|
||||
const handleAddFileToChannel = async (slate, isSelected) => {
|
||||
const prevSlates = [...channels];
|
||||
const resetViewerSlates = () => setChannels(prevSlates);
|
||||
|
||||
if (isSelected) {
|
||||
const newChannels = channels.map((item) => {
|
||||
if (slate.id === item.id) {
|
||||
return { ...item, objects: item.objects.filter((object) => object.id !== file.id) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setChannels(newChannels);
|
||||
|
||||
const response = await UserBehaviors.removeFromSlate({ slate, ids: [file.id] });
|
||||
if (!response) resetViewerSlates();
|
||||
return;
|
||||
}
|
||||
|
||||
const newChannels = channels.map((item) => {
|
||||
if (slate.id === item.id) return { ...item, objects: [...item.objects, file] };
|
||||
return item;
|
||||
});
|
||||
setChannels(newChannels);
|
||||
|
||||
const response = await UserBehaviors.saveCopy({ slate, files: [file], showAlerts: false });
|
||||
if (!response) resetViewerSlates();
|
||||
};
|
||||
|
||||
const handleCreateChannel = (isPublic) => async (name) => {
|
||||
const generatedId = uuid();
|
||||
setChannels([...channels, { id: generatedId, slatename: name, isPublic, objects: [file] }]);
|
||||
|
||||
const response = await Actions.createSlate({
|
||||
name: name,
|
||||
isPublic,
|
||||
hydrateViewer: false,
|
||||
});
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
setChannels(channels.filter((channel) => channel.id !== generatedId));
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE(amine): replace generated id with response
|
||||
const prevChannels = channels.filter((channel) => channel.id !== generatedId);
|
||||
setChannels([...prevChannels, { ...response.slate, objects: [file] }]);
|
||||
|
||||
const saveResponse = await UserBehaviors.saveCopy({
|
||||
slate: response.slate,
|
||||
files: [file],
|
||||
showAlerts: false,
|
||||
});
|
||||
|
||||
if (Events.hasError(saveResponse)) {
|
||||
setChannels([prevChannels, ...response.slate]);
|
||||
}
|
||||
};
|
||||
return [channels, { handleCreateChannel, handleAddFileToChannel }];
|
||||
};
|
||||
|
||||
const useGetPrivateAndPublicChannels = ({ slates, file }) =>
|
||||
React.useMemo(() => {
|
||||
const privateChannels = [];
|
||||
const publicChannels = [];
|
||||
|
||||
slates.forEach((slate) => {
|
||||
const doesContainFile = slate.objects.some((item) => item.id === file.id);
|
||||
|
||||
if (slate.isPublic) {
|
||||
publicChannels.push({ ...slate, doesContainFile });
|
||||
return;
|
||||
}
|
||||
privateChannels.push({ ...slate, doesContainFile });
|
||||
});
|
||||
|
||||
privateChannels.sort((a, b) => a.createdAt - b.createdAt);
|
||||
publicChannels.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
return { privateChannels, publicChannels };
|
||||
}, [slates, file.id]);
|
||||
|
||||
const useChannelsSearch = ({ privateChannels, publicChannels }) => {
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const { results, channelAlreadyExists } = React.useMemo(() => {
|
||||
let channelAlreadyExists = false;
|
||||
|
||||
const results = { privateChannels: [], publicChannels: [] };
|
||||
const searchRegex = new RegExp(query, "gi");
|
||||
|
||||
results.privateChannels = privateChannels.filter((channel) => {
|
||||
if (channel.slatename === query) channelAlreadyExists = true;
|
||||
return searchRegex.test(channel.slatename);
|
||||
});
|
||||
|
||||
results.publicChannels = publicChannels.filter((channel) => {
|
||||
if (channel.slatename === query) channelAlreadyExists = true;
|
||||
return searchRegex.test(channel.slatename);
|
||||
});
|
||||
|
||||
return { results, channelAlreadyExists };
|
||||
}, [query, privateChannels, publicChannels]);
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const nextValue = e.target.value;
|
||||
//NOTE(amine): allow input's value to be empty but keep other validations
|
||||
if (Strings.isEmpty(nextValue) || Validations.slatename(nextValue)) {
|
||||
setQuery(Strings.createSlug(nextValue, ""));
|
||||
}
|
||||
};
|
||||
const clearQuery = () => setQuery("");
|
||||
|
||||
return [
|
||||
{ searchQuery: query, searchResults: results, channelAlreadyExists },
|
||||
{ handleQueryChange, clearQuery },
|
||||
];
|
||||
};
|
||||
|
||||
export function EditChannels({ file, viewer, isOpen, onClose, ...props }) {
|
||||
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
|
||||
viewer,
|
||||
file,
|
||||
});
|
||||
|
||||
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
|
||||
slates: channels,
|
||||
file,
|
||||
});
|
||||
|
||||
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
|
||||
useChannelsSearch({
|
||||
privateChannels: privateChannels,
|
||||
publicChannels: publicChannels,
|
||||
});
|
||||
|
||||
const isSearching = searchQuery.length > 0;
|
||||
|
||||
const showEmptyState = !isSearching && channels.length === 0;
|
||||
|
||||
return (
|
||||
<Jumper.AnimatePresence>
|
||||
{isOpen ? (
|
||||
<Jumper.Root onClose={() => (onClose(), clearQuery())} {...props}>
|
||||
<Jumper.Header style={{ paddingTop: 0, paddingBottom: 0 }}>
|
||||
<ChannelInput
|
||||
value={searchQuery}
|
||||
onChange={handleQueryChange}
|
||||
searchResults={searchResults}
|
||||
autoFocus
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
/>
|
||||
<Jumper.Dismiss />
|
||||
</Jumper.Header>
|
||||
<Jumper.Divider />
|
||||
<Jumper.ObjectInfo file={file} />
|
||||
<Jumper.Divider />
|
||||
{showEmptyState ? (
|
||||
<Jumper.Item style={{ flexGrow: 1 }} css={Styles.CONTAINER_CENTERED}>
|
||||
<ChannelsEmpty />
|
||||
</Jumper.Item>
|
||||
) : (
|
||||
<Jumper.Item style={{ overflowY: "auto", flex: "1 0 0" }}>
|
||||
<Channels
|
||||
header="Private"
|
||||
isCreatingChannel={isSearching && !channelAlreadyExists}
|
||||
channels={isSearching ? searchResults.privateChannels : privateChannels}
|
||||
searchQuery={searchQuery}
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
onCreateChannel={(query) => (handleCreateChannel(false)(query), clearQuery())}
|
||||
file={file}
|
||||
viewer={viewer}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Channels
|
||||
header="Public"
|
||||
isPublic
|
||||
searchQuery={searchQuery}
|
||||
isCreatingChannel={isSearching && !channelAlreadyExists}
|
||||
channels={isSearching ? searchResults.publicChannels : publicChannels}
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
onCreateChannel={(query) => (handleCreateChannel(true)(query), clearQuery())}
|
||||
/>
|
||||
</div>
|
||||
</Jumper.Item>
|
||||
)}
|
||||
</Jumper.Root>
|
||||
) : null}
|
||||
</Jumper.AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditChannelsMobile({ file, viewer, isOpen, onClose }) {
|
||||
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
|
||||
viewer,
|
||||
file,
|
||||
});
|
||||
|
||||
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
|
||||
slates: channels,
|
||||
file,
|
||||
});
|
||||
|
||||
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
|
||||
useChannelsSearch({
|
||||
privateChannels: privateChannels,
|
||||
publicChannels: publicChannels,
|
||||
});
|
||||
|
||||
const isSearching = searchQuery.length > 0;
|
||||
|
||||
return (
|
||||
<MobileJumper.AnimatePresence>
|
||||
{isOpen ? (
|
||||
<MobileJumper.Root onClose={onClose}>
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.ObjectInfo file={file} />
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.Header style={{ paddingTop: 0, paddingBottom: 0 }}>
|
||||
<ChannelInput
|
||||
value={searchQuery}
|
||||
onChange={handleQueryChange}
|
||||
searchResults={searchResults}
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
autoFocus
|
||||
/>
|
||||
</MobileJumper.Header>
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.Content style={{ paddingBottom: 60 }}>
|
||||
<Channels
|
||||
header="Private"
|
||||
isCreatingChannel={isSearching && !channelAlreadyExists}
|
||||
channels={isSearching ? searchResults.privateChannels : privateChannels}
|
||||
searchQuery={searchQuery}
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
onCreateChannel={(query) => (handleCreateChannel(false)(query), clearQuery())}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Channels
|
||||
header="Public"
|
||||
isPublic
|
||||
searchQuery={searchQuery}
|
||||
isCreatingChannel={isSearching && !channelAlreadyExists}
|
||||
channels={isSearching ? searchResults.publicChannels : publicChannels}
|
||||
onAddFileToChannel={handleAddFileToChannel}
|
||||
onCreateChannel={(query) => (handleCreateChannel(true)(query), clearQuery())}
|
||||
/>
|
||||
</div>
|
||||
</MobileJumper.Content>
|
||||
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<System.ButtonPrimitive type="button" onClick={() => (onClose(), clearQuery())}>
|
||||
<SVG.Hash width={16} height={16} style={{ color: Constants.system.blue }} />
|
||||
</System.ButtonPrimitive>
|
||||
</MobileJumper.Footer>
|
||||
</MobileJumper.Root>
|
||||
) : null}
|
||||
</MobileJumper.AnimatePresence>
|
||||
);
|
||||
}
|
@ -0,0 +1,757 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as System from "~/components/system";
|
||||
import * as Jumper from "~/components/system/components/fragments/Jumper";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as UserBehaviors from "~/common/user-behaviors";
|
||||
import * as MobileJumper from "~/components/system/components/fragments/MobileJumper";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as RovingTabIndex from "~/components/core/RovingTabIndex";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { mergeEvents } from "~/common/utilities";
|
||||
import { css } from "@emotion/react";
|
||||
import { useEventListener } from "~/common/hooks";
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Combobox
|
||||
* used internally by EditSlates jumper
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const comboboxContext = React.createContext({});
|
||||
const useComboboxContext = () => React.useContext(comboboxContext);
|
||||
|
||||
function ComboboxProvider({ children, isMobile = false, onItemSelect }) {
|
||||
const menuItemsRef = React.useRef({});
|
||||
const menuElementRef = React.useRef({});
|
||||
|
||||
const initialIndex = 0;
|
||||
const [selectedIdx, setSelectedIdx] = React.useState(initialIndex);
|
||||
|
||||
const registerMenuRef = (node) => {
|
||||
if (isMobile) return;
|
||||
menuElementRef.current = node;
|
||||
};
|
||||
|
||||
const registerMenuItem = ({ index, onSelect, ref }) => {
|
||||
if (isMobile) return;
|
||||
menuItemsRef.current[index] = { index, onSelect, ref };
|
||||
};
|
||||
const cleanupMenuItem = (index) => {
|
||||
if (isMobile) return;
|
||||
if (index === selectedIdx) setSelectedIdx(initialIndex);
|
||||
delete menuItemsRef.current[index];
|
||||
};
|
||||
|
||||
const isNavigatingViaKeyboard = React.useRef(true);
|
||||
const moveSelectionOnArrowUp = () => {
|
||||
isNavigatingViaKeyboard.current = true;
|
||||
if (isMobile) return;
|
||||
const prevIndex = selectedIdx - 1;
|
||||
let prevFocusedIndex = null;
|
||||
if (prevIndex >= initialIndex) {
|
||||
prevFocusedIndex = prevIndex;
|
||||
} else {
|
||||
prevFocusedIndex = Math.max(...Object.keys(menuItemsRef.current));
|
||||
}
|
||||
setSelectedIdx(prevFocusedIndex);
|
||||
};
|
||||
|
||||
const moveSelectionOnArrowDown = () => {
|
||||
isNavigatingViaKeyboard.current = true;
|
||||
if (isMobile) return;
|
||||
const nextIndex = selectedIdx + 1;
|
||||
const elementExists = menuItemsRef.current[nextIndex];
|
||||
const nextFocusedIndex = elementExists ? nextIndex : initialIndex;
|
||||
setSelectedIdx(nextFocusedIndex);
|
||||
};
|
||||
|
||||
const moveSelectionOnHover = (index) => {
|
||||
isNavigatingViaKeyboard.current = false;
|
||||
const elementExists = menuItemsRef.current[index];
|
||||
if (!elementExists) {
|
||||
console.warn("Combobox: The element you're trying to select doesn't exist");
|
||||
return;
|
||||
}
|
||||
setSelectedIdx(index);
|
||||
};
|
||||
|
||||
const applySelectedElement = () => {
|
||||
if (isMobile) return;
|
||||
menuItemsRef.current[selectedIdx].onSelect(), onItemSelect?.();
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isMobile) return;
|
||||
|
||||
//NOTE(amine): don't scroll automatically when the user is navigating using a mouse
|
||||
if (!isNavigatingViaKeyboard.current) return;
|
||||
const menuNode = menuElementRef.current;
|
||||
const selectedNode = menuItemsRef.current[selectedIdx]?.ref?.current;
|
||||
if (!menuNode || !selectedNode) return;
|
||||
|
||||
const menuTop = menuNode.scrollTop;
|
||||
const menuBottom = menuTop + menuNode.offsetHeight;
|
||||
|
||||
const selectedNodeTop = selectedNode.offsetTop;
|
||||
const selectedNodeBottom = selectedNodeTop + selectedNode.offsetHeight;
|
||||
|
||||
if (selectedNodeTop <= menuTop) {
|
||||
menuNode.scrollTo({ top: selectedNodeTop - selectedNode.offsetHeight });
|
||||
}
|
||||
|
||||
if (selectedNodeBottom >= menuBottom) {
|
||||
menuNode.scrollTo({
|
||||
top: selectedNodeBottom - menuNode.offsetHeight + selectedNode.offsetHeight,
|
||||
});
|
||||
}
|
||||
}, [selectedIdx, isMobile]);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => [
|
||||
{ selectedIdx, isMobile, isSelectedViaKeyboard: isNavigatingViaKeyboard.current },
|
||||
{
|
||||
onItemSelect,
|
||||
|
||||
registerMenuItem,
|
||||
cleanupMenuItem,
|
||||
|
||||
moveSelectionOnArrowUp,
|
||||
moveSelectionOnArrowDown,
|
||||
moveSelectionOnHover,
|
||||
applySelectedElement,
|
||||
|
||||
registerMenuRef,
|
||||
},
|
||||
],
|
||||
[selectedIdx, isMobile]
|
||||
);
|
||||
|
||||
return <comboboxContext.Provider value={contextValue}>{children}</comboboxContext.Provider>;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const ComboboxInput = React.forwardRef(({ onKeyDown, ...props }, ref) => {
|
||||
const [, { moveSelectionOnArrowUp, moveSelectionOnArrowDown, applySelectedElement }] =
|
||||
useComboboxContext();
|
||||
|
||||
const keyDownHandler = (e) => {
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveSelectionOnArrowUp();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveSelectionOnArrowDown();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
applySelectedElement();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return <System.Input onKeyDown={mergeEvents(keyDownHandler, onKeyDown)} {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
function ComboboxMenuButton({ children, index, onSelect, onMouseDown, onClick, css, ...props }) {
|
||||
const [
|
||||
{ selectedIdx },
|
||||
{ registerMenuItem, cleanupMenuItem, moveSelectionOnHover, onItemSelect },
|
||||
] = useComboboxContext();
|
||||
const handleMouseDown = (e) => e.preventDefault();
|
||||
const handleClick = () => (onSelect?.(), onItemSelect?.());
|
||||
|
||||
const ref = React.useRef();
|
||||
React.useEffect(() => {
|
||||
registerMenuItem({ index, onSelect, ref });
|
||||
return () => cleanupMenuItem(index);
|
||||
}, [index, onSelect]);
|
||||
|
||||
const onMouseMoveHandler = () => {
|
||||
if (selectedIdx !== index) moveSelectionOnHover(index);
|
||||
};
|
||||
useEventListener(
|
||||
{ type: "mousemove", handler: onMouseMoveHandler, ref, options: { once: true } },
|
||||
[selectedIdx]
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
onMouseDown={mergeEvents(handleMouseDown, onMouseDown)}
|
||||
onClick={mergeEvents(handleClick, onClick)}
|
||||
css={[Styles.BUTTON_RESET, css]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STYLES_COMBOBOX_MENU = css`
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
function ComboboxMenu({ children, css, ...props }) {
|
||||
const [, { registerMenuRef }] = useComboboxContext();
|
||||
|
||||
return (
|
||||
<ul ref={registerMenuRef} css={[STYLES_COMBOBOX_MENU, css]} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const Combobox = {
|
||||
Provider: ComboboxProvider,
|
||||
Input: ComboboxInput,
|
||||
Menu: ComboboxMenu,
|
||||
MenuButton: ComboboxMenuButton,
|
||||
};
|
||||
|
||||
const useCombobox = () => {
|
||||
const [{ selectedIdx, isSelectedViaKeyboard, isMobile }] = useComboboxContext();
|
||||
const checkIfIndexSelected = (index) => {
|
||||
if (isMobile) return false;
|
||||
return selectedIdx === index;
|
||||
};
|
||||
return { checkIfIndexSelected, isSelectedViaKeyboard };
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* EditSlates Internals
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STYLES_APPLIED_SLATE_BUTTON = (theme) => css`
|
||||
${Styles.HORIZONTAL_CONTAINER_CENTERED};
|
||||
height: 32px;
|
||||
padding: 5px 12px 7px;
|
||||
border-radius: 12px;
|
||||
background-color: ${theme.semantic.bgWhite};
|
||||
border: 1px solid ${theme.semantic.borderGrayLight};
|
||||
box-shadow: ${theme.shadow.lightSmall};
|
||||
`;
|
||||
|
||||
const STYLES_APPLIED_COLOR_TEXTBLACK = (theme) => css`
|
||||
color: ${theme.semantic.textBlack};
|
||||
`;
|
||||
const STYLES_APPLIED_COLOR_TEXTGRAY = (theme) => css`
|
||||
color: ${theme.semantic.textGray};
|
||||
`;
|
||||
|
||||
const AppliedSlateButton = React.forwardRef(({ hasPublicIcon, children, ...props }, ref) => {
|
||||
return (
|
||||
<System.ButtonPrimitive css={STYLES_APPLIED_SLATE_BUTTON} ref={ref} {...props}>
|
||||
{hasPublicIcon && (
|
||||
<SVG.Users
|
||||
width={16}
|
||||
height={16}
|
||||
css={STYLES_APPLIED_COLOR_TEXTBLACK}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
)}
|
||||
<System.H5
|
||||
as="span"
|
||||
style={{ maxWidth: "35ch" }}
|
||||
nbrOflines={1}
|
||||
title={children}
|
||||
color="textBlack"
|
||||
>
|
||||
{children}
|
||||
</System.H5>
|
||||
<SVG.Dismiss width={16} style={{ marginLeft: 4 }} css={STYLES_APPLIED_COLOR_TEXTGRAY} />
|
||||
</System.ButtonPrimitive>
|
||||
);
|
||||
});
|
||||
|
||||
const STYLES_SLATES_INPUT = (theme) => css`
|
||||
background-color: transparent;
|
||||
${theme.semantic.textGray};
|
||||
box-shadow: none;
|
||||
height: 24px;
|
||||
padding: 0px;
|
||||
border-radius: 0px;
|
||||
::placeholder {
|
||||
color: ${theme.semantic.textGray};
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_SLATES_INPUT_WRAPPER = css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: calc(-8px + 6px) 0 0 -8px;
|
||||
width: calc(100% + 8px);
|
||||
height: 52px;
|
||||
max-height: 96px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
|
||||
& > * {
|
||||
margin: 8px 0 0 8px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_SEARCH_SLATES_COLOR = (theme) => css`
|
||||
color: ${theme.semantic.textGrayDark};
|
||||
`;
|
||||
|
||||
function ComboboxSlatesInput({ appliedSlates, removeFileFromSlate, ...props }) {
|
||||
return (
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER, STYLES_SEARCH_SLATES_COLOR]} style={{ width: "100%" }}>
|
||||
<SVG.Hash width={16} style={{ marginTop: 20, marginBottom: 16 }} />
|
||||
<RovingTabIndex.Provider>
|
||||
<RovingTabIndex.List
|
||||
css={STYLES_SLATES_INPUT_WRAPPER}
|
||||
style={{ marginLeft: 6, marginTop: 6, paddingRight: 20 }}
|
||||
>
|
||||
{appliedSlates.map((slate, idx) => (
|
||||
<RovingTabIndex.Item key={slate.id} index={idx}>
|
||||
<AppliedSlateButton
|
||||
hasPublicIcon={slate.isPublic}
|
||||
onClick={() => removeFileFromSlate(slate)}
|
||||
>
|
||||
{slate.slatename}
|
||||
</AppliedSlateButton>
|
||||
</RovingTabIndex.Item>
|
||||
))}
|
||||
<RovingTabIndex.Item index={appliedSlates.length}>
|
||||
<Combobox.Input
|
||||
name="search"
|
||||
placeholder="Search or create a new tag"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
inputCss={STYLES_SLATES_INPUT}
|
||||
containerStyle={{ flexGrow: 1, paddingTop: 3, height: 32 }}
|
||||
{...props}
|
||||
/>
|
||||
</RovingTabIndex.Item>
|
||||
</RovingTabIndex.List>
|
||||
</RovingTabIndex.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STYLES_SLATES_MENU_WRAPPER = (theme) => css`
|
||||
max-height: 275px;
|
||||
padding: 12px;
|
||||
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
padding: 8px;
|
||||
max-height: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_RETURN_KEY = (theme) => css`
|
||||
padding: 0px 2px;
|
||||
border-radius: 6px;
|
||||
background-color: ${theme.semantic.bgBlurLightOP};
|
||||
`;
|
||||
|
||||
const STYLES_SLATES_MENU_BUTTON_SELECTED = (theme) => css`
|
||||
background-color: ${theme.semantic.bgGrayLight4};
|
||||
`;
|
||||
|
||||
const STYLES_SLATES_MENU_BUTTON = css`
|
||||
${Styles.HORIZONTAL_CONTAINER_CENTERED};
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
padding: 9px 8px 11px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
:hover {
|
||||
color: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_CHECKBOX_CIRCLE = (theme) => css`
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: ${theme.system.green};
|
||||
color: ${theme.semantic.textWhite};
|
||||
`;
|
||||
|
||||
const ComboboxSlatesMenuButton = ({
|
||||
hasPublicIcon,
|
||||
isCreateAction,
|
||||
isSlateApplied,
|
||||
children,
|
||||
index,
|
||||
...props
|
||||
}) => {
|
||||
const { checkIfIndexSelected, isSelectedViaKeyboard } = useCombobox();
|
||||
const isSelected = checkIfIndexSelected(index);
|
||||
|
||||
return (
|
||||
<Combobox.MenuButton
|
||||
css={[
|
||||
STYLES_SLATES_MENU_BUTTON,
|
||||
isSelected && STYLES_SLATES_MENU_BUTTON_SELECTED,
|
||||
(theme) => css({ color: isCreateAction ? theme.system.blue : theme.system.textBlack }),
|
||||
]}
|
||||
index={index}
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
{hasPublicIcon && (
|
||||
<div style={{ position: "absolute", left: "8px", top: "9x" }}>
|
||||
<SVG.Users width={20} height={20} />
|
||||
</div>
|
||||
)}
|
||||
<System.H5
|
||||
as="span"
|
||||
nbrOflines={isCreateAction ? 2 : 1}
|
||||
title={children}
|
||||
style={{ marginLeft: 32, maxWidth: "50ch" }}
|
||||
>
|
||||
{children}
|
||||
</System.H5>
|
||||
</div>
|
||||
{!isCreateAction && isSelected && isSelectedViaKeyboard && (
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginLeft: "auto" }}>
|
||||
<System.P3 color="textGrayDark">{isSlateApplied ? "remove tag" : "apply tag"}</System.P3>
|
||||
<System.P3 css={STYLES_RETURN_KEY} color="textGray" style={{ marginLeft: 4 }}>
|
||||
⏎
|
||||
</System.P3>
|
||||
</div>
|
||||
)}
|
||||
{isSlateApplied && (
|
||||
<div css={STYLES_CHECKBOX_CIRCLE} style={{ marginLeft: 12 }}>
|
||||
<SVG.Check width={12} height={12} />
|
||||
</div>
|
||||
)}
|
||||
</Combobox.MenuButton>
|
||||
);
|
||||
};
|
||||
|
||||
function ComboboxSlatesMenu({
|
||||
filterValue,
|
||||
filteredSlates,
|
||||
|
||||
createSlate,
|
||||
addFileToSlate,
|
||||
removeFileFromSlate,
|
||||
}) {
|
||||
const { canCreateSlate, suggestions } = React.useMemo(() => {
|
||||
let canCreateSlate = true;
|
||||
|
||||
const filterRegex = new RegExp(filterValue, "gi");
|
||||
const filterAndSortSlates = (slates) =>
|
||||
slates
|
||||
.filter((slate) => {
|
||||
if (slate.slatename === filterValue) canCreateSlate = false;
|
||||
return filterRegex.test(slate.slatename);
|
||||
})
|
||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
return {
|
||||
suggestions: {
|
||||
applied: filterAndSortSlates(filteredSlates.applied),
|
||||
unapplied: filterAndSortSlates(filteredSlates.unapplied),
|
||||
},
|
||||
canCreateSlate,
|
||||
};
|
||||
}, [filterValue, filteredSlates]);
|
||||
|
||||
const isFilteredView = !!filterValue;
|
||||
|
||||
const createPublicState = React.useCallback(() => {
|
||||
createSlate({ name: filterValue, isPublic: true });
|
||||
}, [filterValue]);
|
||||
const createPrivateState = React.useCallback(() => {
|
||||
createSlate({ name: filterValue, isPublic: false });
|
||||
}, [filterValue]);
|
||||
|
||||
if (isFilteredView) {
|
||||
return (
|
||||
<Combobox.Menu css={STYLES_SLATES_MENU_WRAPPER}>
|
||||
{suggestions.unapplied.map((slate, i) => (
|
||||
<ComboboxSlatesMenuButton
|
||||
key={slate.id}
|
||||
hasPublicIcon={slate.isPublic}
|
||||
css={STYLES_SLATES_MENU_BUTTON}
|
||||
index={i}
|
||||
onSelect={() => addFileToSlate(slate)}
|
||||
>
|
||||
{slate.slatename}
|
||||
</ComboboxSlatesMenuButton>
|
||||
))}
|
||||
{canCreateSlate ? (
|
||||
<>
|
||||
<ComboboxSlatesMenuButton
|
||||
isCreateAction
|
||||
index={suggestions.unapplied.length}
|
||||
onSelect={createPrivateState}
|
||||
>
|
||||
create new private tag “{filterValue}”
|
||||
</ComboboxSlatesMenuButton>
|
||||
<ComboboxSlatesMenuButton
|
||||
isCreateAction
|
||||
hasPublicIcon
|
||||
index={suggestions.unapplied.length + 1}
|
||||
onSelect={createPublicState}
|
||||
>
|
||||
create new shared tag “{filterValue}” (shows on your profile)
|
||||
</ComboboxSlatesMenuButton>
|
||||
</>
|
||||
) : null}
|
||||
{suggestions.applied.map((slate, i) => (
|
||||
<ComboboxSlatesMenuButton
|
||||
isSlateApplied
|
||||
hasPublicIcon={slate.isPublic}
|
||||
index={
|
||||
canCreateSlate
|
||||
? suggestions.unapplied.length + i + 2
|
||||
: suggestions.unapplied.length + i
|
||||
}
|
||||
key={slate.id}
|
||||
onSelect={() => removeFileFromSlate(slate)}
|
||||
>
|
||||
{slate.slatename}
|
||||
</ComboboxSlatesMenuButton>
|
||||
))}
|
||||
</Combobox.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox.Menu css={STYLES_SLATES_MENU_WRAPPER}>
|
||||
{suggestions.unapplied.map((slate, i) => (
|
||||
<ComboboxSlatesMenuButton
|
||||
hasPublicIcon={slate.isPublic}
|
||||
key={slate.id}
|
||||
index={i}
|
||||
onSelect={() => addFileToSlate(slate)}
|
||||
>
|
||||
{slate.slatename}
|
||||
</ComboboxSlatesMenuButton>
|
||||
))}
|
||||
{suggestions.applied.map((slate, i) => (
|
||||
<ComboboxSlatesMenuButton
|
||||
isSlateApplied
|
||||
hasPublicIcon={slate.isPublic}
|
||||
key={slate.id}
|
||||
index={suggestions.unapplied.length + i}
|
||||
onSelect={() => removeFileFromSlate(slate)}
|
||||
>
|
||||
{slate.slatename}
|
||||
</ComboboxSlatesMenuButton>
|
||||
))}
|
||||
</Combobox.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* EditSlates Jumper
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const useSlates = ({ viewer, object }) => {
|
||||
const [slates, setSlates] = React.useState(viewer.slates);
|
||||
|
||||
const filteredSlates = React.useMemo(() => {
|
||||
let applied = [];
|
||||
let unapplied = [];
|
||||
|
||||
slates.forEach((slate) => {
|
||||
if (slate.objects.some((item) => item.id === object.id)) {
|
||||
applied.push(slate);
|
||||
} else {
|
||||
unapplied.push(slate);
|
||||
}
|
||||
});
|
||||
|
||||
return { applied, unapplied };
|
||||
}, [slates, object]);
|
||||
|
||||
const createSlate = async ({ name, isPublic }) => {
|
||||
const generatedId = uuid();
|
||||
setSlates([
|
||||
...slates,
|
||||
{
|
||||
id: generatedId,
|
||||
slatename: name,
|
||||
isPublic,
|
||||
objects: [object],
|
||||
updatedAt: new Date().toString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await Actions.createSlate({
|
||||
name: name,
|
||||
isPublic,
|
||||
hydrateViewer: false,
|
||||
});
|
||||
if (Events.hasError(response)) {
|
||||
setSlates(slates.filter((slate) => slate.id !== generatedId));
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE(amine): replace generated id with response
|
||||
const prevSlates = slates.filter((slate) => slate.id !== generatedId);
|
||||
setSlates([...prevSlates, { ...response.slate, objects: [object] }]);
|
||||
|
||||
const saveResponse = await UserBehaviors.saveCopy({
|
||||
slate: response.slate,
|
||||
files: [object],
|
||||
showAlerts: false,
|
||||
});
|
||||
if (Events.hasError(saveResponse)) {
|
||||
setSlates([prevSlates, ...response.slate]);
|
||||
}
|
||||
};
|
||||
|
||||
const addFileToSlate = async (slate) => {
|
||||
const prevSlates = [...slates];
|
||||
const resetViewerSlates = () => setSlates(prevSlates);
|
||||
|
||||
const nextSlates = slates.map((item) => {
|
||||
if (slate.id === item.id) return { ...item, objects: [...item.objects, object] };
|
||||
return item;
|
||||
});
|
||||
setSlates(nextSlates);
|
||||
|
||||
const response = await UserBehaviors.saveCopy({ slate, files: [object], showAlerts: false });
|
||||
if (!response) resetViewerSlates();
|
||||
};
|
||||
|
||||
const removeFileFromSlate = async (slate) => {
|
||||
const prevSlates = [...slates];
|
||||
const resetViewerSlates = () => setSlates(prevSlates);
|
||||
const nextSlates = slates.map((item) => {
|
||||
if (slate.id === item.id) {
|
||||
return { ...item, objects: item.objects.filter((object) => object.id !== object.id) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setSlates(nextSlates);
|
||||
|
||||
const response = await UserBehaviors.removeFromSlate({ slate, ids: [object.id] });
|
||||
if (!response) resetViewerSlates();
|
||||
};
|
||||
|
||||
return {
|
||||
slates,
|
||||
filteredSlates,
|
||||
createSlate,
|
||||
addFileToSlate,
|
||||
removeFileFromSlate,
|
||||
};
|
||||
};
|
||||
|
||||
const useInput = () => {
|
||||
const [value, setValue] = React.useState("");
|
||||
const handleInputChange = (e) => {
|
||||
const nextValue = e.target.value;
|
||||
//NOTE(amine): allow input's value to be empty but keep other validations
|
||||
if (Strings.isEmpty(nextValue) || Validations.slatename(nextValue)) {
|
||||
setValue(Strings.createSlug(nextValue, ""));
|
||||
}
|
||||
};
|
||||
const clearInputValue = () => setValue("");
|
||||
|
||||
return [value, { handleInputChange, clearInputValue }];
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
export function EditSlates({ file, viewer, onClose, ...props }) {
|
||||
const { filteredSlates, createSlate, addFileToSlate, removeFileFromSlate } = useSlates({
|
||||
viewer,
|
||||
object: file,
|
||||
});
|
||||
|
||||
const [value, { handleInputChange, clearInputValue }] = useInput();
|
||||
|
||||
return (
|
||||
<Jumper.Root onClose={onClose} {...props}>
|
||||
<Combobox.Provider onItemSelect={clearInputValue}>
|
||||
<Jumper.Header style={{ paddingTop: 0, paddingBottom: 0, paddingRight: 0 }}>
|
||||
<ComboboxSlatesInput
|
||||
value={value}
|
||||
appliedSlates={filteredSlates.applied}
|
||||
removeFileFromSlate={removeFileFromSlate}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Jumper.Header>
|
||||
<Jumper.Divider />
|
||||
<Jumper.ObjectInfo file={file} />
|
||||
<Jumper.Divider />
|
||||
<Jumper.Item style={{ padding: 0 }}>
|
||||
<ComboboxSlatesMenu
|
||||
filterValue={value}
|
||||
filteredSlates={filteredSlates}
|
||||
createSlate={createSlate}
|
||||
addFileToSlate={addFileToSlate}
|
||||
removeFileFromSlate={removeFileFromSlate}
|
||||
/>
|
||||
</Jumper.Item>
|
||||
</Combobox.Provider>
|
||||
</Jumper.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
export function EditSlatesMobile({ file, viewer, onClose }) {
|
||||
const { filteredSlates, createSlate, addFileToSlate, removeFileFromSlate } = useSlates({
|
||||
viewer,
|
||||
object: file,
|
||||
});
|
||||
|
||||
const [value, { handleInputChange, clearInputValue }] = useInput();
|
||||
|
||||
return (
|
||||
<MobileJumper.Root onClose={onClose}>
|
||||
<Combobox.Provider onItemSelect={clearInputValue} isMobile>
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.ObjectInfo file={file} />
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.Header style={{ paddingTop: 0, paddingBottom: 0, paddingRight: 0 }}>
|
||||
<ComboboxSlatesInput
|
||||
value={value}
|
||||
appliedSlates={filteredSlates.applied}
|
||||
removeFileFromSlate={removeFileFromSlate}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</MobileJumper.Header>
|
||||
<System.Divider height={1} color="borderGrayLight" />
|
||||
<MobileJumper.Content style={{ padding: 0, paddingBottom: 60 }}>
|
||||
<ComboboxSlatesMenu
|
||||
filterValue={value}
|
||||
filteredSlates={filteredSlates}
|
||||
createSlate={createSlate}
|
||||
addFileToSlate={addFileToSlate}
|
||||
removeFileFromSlate={removeFileFromSlate}
|
||||
/>
|
||||
</MobileJumper.Content>
|
||||
</Combobox.Provider>
|
||||
</MobileJumper.Root>
|
||||
);
|
||||
}
|
@ -13,9 +13,9 @@ import {
|
||||
import { Share, ShareMobile } from "~/components/system/components/GlobalCarousel/jumpers/Share";
|
||||
|
||||
import {
|
||||
EditChannels,
|
||||
EditChannelsMobile,
|
||||
} from "~/components/system/components/GlobalCarousel/jumpers/EditChannels";
|
||||
EditSlates,
|
||||
EditSlatesMobile,
|
||||
} from "~/components/system/components/GlobalCarousel/jumpers/EditSlates";
|
||||
|
||||
export {
|
||||
//NOTE(amine): FileDescription jumper
|
||||
@ -29,7 +29,7 @@ export {
|
||||
//NOTE(amine): Share jumper
|
||||
Share,
|
||||
ShareMobile,
|
||||
//NOTE(amine): EditChannels jumper
|
||||
EditChannels,
|
||||
EditChannelsMobile,
|
||||
//NOTE(amine): EditSlates jumper
|
||||
EditSlates,
|
||||
EditSlatesMobile,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user