Merge pull request #973 from filecoin-project/@aminejv/filter-updates

Add tags to the filter sidebar
This commit is contained in:
martinalong 2021-11-29 13:34:46 -08:00 committed by GitHub
commit 9106c47a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 753 additions and 733 deletions

View File

@ -4,15 +4,16 @@ export const values = {
}; };
export const sizes = { export const sizes = {
mobile: 768,
navigation: 288,
sidebar: 416,
// NOTE(amine): header's height + filter navbar's height
header: 52, header: 52,
navigation: 288,
intercomWidget: 60,
sidebar: 416,
filterNavbar: 40, filterNavbar: 40,
topOffset: 0, //NOTE(martina): Pushes UI down. 16 when there is a persistent announcement banner, 0 otherwise
mobile: 768,
tablet: 960, tablet: 960,
desktop: 1024, desktop: 1024,
topOffset: 0, //NOTE(martina): Pushes UI down. 16 when there is a persistent announcement banner, 0 otherwise
}; };
export const system = { export const system = {

View File

@ -1,86 +0,0 @@
import {
isImageType,
isVideoType,
isAudioType,
isDocument,
isTwitterLink,
isYoutubeLink,
isTwitchLink,
isGithubLink,
isInstagramLink,
} from "~/common/validations";
export const FILTER_VIEWS_IDS = {
initial: "initial",
browser: "browser",
};
export const FILTER_SUBVIEWS_IDS = {
browser: { saved: "saved" },
};
export const FILTER_TYPES = {
[FILTER_VIEWS_IDS.initial]: {
filters: {
initial: "library",
library: "library",
images: "images",
videos: "videos",
audios: "audios",
documents: "documents",
},
},
[FILTER_VIEWS_IDS.browser]: {
filters: { all: "all", initial: "all" },
subviews: {
[FILTER_SUBVIEWS_IDS.browser.saved]: {
filters: {
initial: "all",
all: "all",
twitter: "twitter",
youtube: "youtube",
twitch: "twitch",
github: "github",
instagram: "instagram",
},
},
},
},
};
const FILTERING_HANDLERS = {
[FILTER_VIEWS_IDS.initial]: {
filters: {
library: (object) => object,
images: (object) => isImageType(object.type),
videos: (object) => isVideoType(object.type),
audios: (object) => isAudioType(object.type),
documents: (object) => isDocument(object.filename, object.type),
},
},
[FILTER_VIEWS_IDS.browser]: {
filters: { all: (object) => object.isLink },
subviews: {
[FILTER_SUBVIEWS_IDS.browser.saved]: {
filters: {
all: (object) => object.isLink,
twitter: isTwitterLink,
youtube: isYoutubeLink,
twitch: isTwitchLink,
github: isGithubLink,
instagram: isInstagramLink,
},
},
},
},
};
export const getViewData = (view) => {
return FILTER_TYPES[view];
};
export const getFilterHandler = ({ view, subview, type }) => {
const nextView = FILTERING_HANDLERS[view];
if (subview) return nextView.subviews[subview].filters[type];
return nextView.filters[type];
};

View File

@ -403,33 +403,6 @@ export const useLockScroll = ({ lock = true } = { lock: true }) => {
}, [lock]); }, [lock]);
}; };
export const useWorker = ({ onStart, onMessage, onError } = {}, dependencies = []) => {
const workerRef = React.useRef();
const onStartRef = React.useRef();
onStartRef.current = onStart;
const onMessageRef = React.useRef();
onMessageRef.current = onMessage;
const onErrorRef = React.useRef();
onErrorRef.current = onError;
React.useEffect(() => {
const worker = new Worker(new URL("../workers/filter-files.js", import.meta.url));
if (!worker) return;
workerRef.current = worker;
worker.onmessage = onMessageRef.current;
worker.onerror = onErrorRef.current;
onStartRef.current(worker);
return () => worker?.terminate();
}, dependencies);
return workerRef.current;
};
export const useHover = () => { export const useHover = () => {
const [isHovered, setHoverState] = React.useState(false); const [isHovered, setHoverState] = React.useState(false);
@ -486,3 +459,9 @@ export const useDetectTextOverflow = ({ ref }, dependencies) => {
return isTextOverflowing; return isTextOverflowing;
}; };
let cache = {};
export const useCache = () => {
const setCache = ({ key, value }) => (cache[key] = value);
return [cache, setCache];
};

View File

@ -46,6 +46,7 @@ import ApplicationLayout from "~/components/core/ApplicationLayout";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper"; import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
import PortalsProvider from "~/components/core/PortalsProvider"; import PortalsProvider from "~/components/core/PortalsProvider";
import CTATransition from "~/components/core/CTATransition"; import CTATransition from "~/components/core/CTATransition";
import Filter from "~/components/core/Filter";
import { GlobalModal } from "~/components/system/components/GlobalModal"; import { GlobalModal } from "~/components/system/components/GlobalModal";
import { OnboardingModal } from "~/components/core/OnboardingModal"; import { OnboardingModal } from "~/components/core/OnboardingModal";
@ -347,6 +348,7 @@ export default class ApplicationPage extends React.Component {
let body = document.documentElement || document.body; let body = document.documentElement || document.body;
if (page.id === "NAV_SLATE" || page.id === "NAV_PROFILE") { if (page.id === "NAV_SLATE" || page.id === "NAV_PROFILE") {
state.loading = true; state.loading = true;
state.data = { id: details.id };
} }
this.setState(state, () => { this.setState(state, () => {
if (!popstate) { if (!popstate) {
@ -494,19 +496,28 @@ export default class ApplicationPage extends React.Component {
isMac={this.props.isMac} isMac={this.props.isMac}
viewer={this.state.viewer} viewer={this.state.viewer}
> >
{this.state.loading ? ( <Filter
<div isActive={!!this.state.viewer}
css={Styles.CONTAINER_CENTERED} viewer={this.state.viewer}
style={{ page={page}
width: "100vw", data={this.state.data}
height: "100vh", isMobile={this.props.isMobile}
}} onAction={this._handleAction}
> >
<LoaderSpinner style={{ height: 32, width: 32 }} /> {this.state.loading ? (
</div> <div
) : ( css={Styles.CONTAINER_CENTERED}
scene style={{
)} width: "100%",
height: "100vh",
}}
>
<LoaderSpinner style={{ height: 32, width: 32 }} />
</div>
) : (
scene
)}
</Filter>
</ApplicationLayout> </ApplicationLayout>
</PortalsProvider> </PortalsProvider>
<GlobalModal /> <GlobalModal />

View File

@ -1,11 +1,9 @@
import * as React from "react"; import * as React from "react";
import * as Constants from "~/common/constants"; import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg"; import * as SVG from "~/common/svg";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles"; import * as Styles from "~/common/styles";
import * as Upload from "~/components/core/Upload"; import * as Upload from "~/components/core/Upload";
import * as Filter from "~/components/core/Filter"; import * as Search from "~/components/core/Search";
import * as Actions from "~/common/actions";
import { import {
ApplicationUserControls, ApplicationUserControls,
@ -18,30 +16,10 @@ import { Link } from "~/components/core/Link";
import { ButtonPrimary, ButtonTertiary } from "~/components/system/components/Buttons"; import { ButtonPrimary, ButtonTertiary } from "~/components/system/components/Buttons";
import { Match, Switch } from "~/components/utility/Switch"; import { Match, Switch } from "~/components/utility/Switch";
import { Show } from "~/components/utility/Show"; import { Show } from "~/components/utility/Show";
import { useField, useMediaQuery } from "~/common/hooks"; import { useMediaQuery } from "~/common/hooks";
import { Input } from "~/components/system/components/Input";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Navbar as FilterNavbar } from "~/components/core/Filter/Navbar";
const STYLES_SEARCH_COMPONENT = (theme) => css` import { useSearchStore } from "~/components/core/Search/store";
background-color: transparent;
box-shadow: none;
height: 100%;
input {
height: 100%;
padding: 0px 4px;
border-radius: 0px;
}
input::placeholder {
color: ${theme.semantic.textGray};
font-size: ${theme.typescale.lvl1};
}
`;
const STYLES_DISMISS_BUTTON = (theme) => css`
display: block;
${Styles.BUTTON_RESET};
color: ${theme.semantic.textGray};
`;
const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css` const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css`
position: absolute; position: absolute;
@ -51,7 +29,6 @@ const STYLES_APPLICATION_HEADER_BACKGROUND = (theme) => css`
left: 0; left: 0;
z-index: -1; z-index: -1;
background-color: ${theme.system.white}; background-color: ${theme.system.white};
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) { @supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px); -webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px); backdrop-filter: blur(75px);
@ -105,9 +82,10 @@ const STYLES_BACKGROUND = css`
animation: fade-in 200ms ease-out; animation: fade-in 200ms ease-out;
`; `;
const STYLES_HEADER = css` const STYLES_HEADER = (theme) => css`
z-index: ${Constants.zindex.header}; z-index: ${theme.zindex.header};
width: 100vw; width: 100vw;
height: ${theme.sizes.header}px;
position: fixed; position: fixed;
right: 0; right: 0;
top: 0; top: 0;
@ -118,7 +96,7 @@ const STYLES_FILTER_NAVBAR = (theme) => css`
width: 100vw; width: 100vw;
position: fixed; position: fixed;
right: 0; right: 0;
top: ${theme.sizes.header}; top: ${theme.sizes.header}px;
`; `;
const STYLES_UPLOAD_BUTTON = css` const STYLES_UPLOAD_BUTTON = css`
@ -142,7 +120,6 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
showDropdown: false, showDropdown: false,
popup: null, popup: null,
isRefreshing: false, isRefreshing: false,
query: "",
}); });
const _handleTogglePopup = (value) => { const _handleTogglePopup = (value) => {
@ -153,38 +130,10 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
} }
}; };
const _handleInputChange = (e) => { const { isSearching } = useSearchStore();
setState((prev) => ({ ...prev, query: e.target.value }));
};
//TODO(amine): plug in the right values
//for more context, look at node_common/managers/search/search.js
//userId: (optional) the id of the user whose stuff we are searching through. If specified, globalSearch is disregarded since the search will be limited to that user's public items. Does not apply when searching for type USER
//types: leaving it null searches everything. Doing ["SLATE"] searches just slates, doing ["USER", "FILE"] searches users and files.
//globalSearch: whether you are just searching the user's files/slates or global files/slates. This option doesn't exist for searching users since there is no notion of public or private users
//tagIds: only applies when searching files. the ids of the tags (aka collections) you are searching within. aka if you only want to search for files in a given slate, provide that slate's id. If no tag ids are provided, it searches all files
//grouped: whether to group the results by type (slate, user, file) when searching multiple types. Doesn't apply when searching only one type e.g. types: ["SLATE"]
const handleSearch = async () => {
const response = await Actions.search({
types: null,
query: state.query,
globalSearch: true,
tagIds: null,
grouped: true,
});
console.log(response);
};
const { getFieldProps, value: searchQuery, setFieldValue } = useField({
initialValue: "",
onSubmit: handleSearch,
});
const handleDismissSearch = () => setFieldValue("");
const { mobile } = useMediaQuery(); const { mobile } = useMediaQuery();
const isSignedOut = !viewer; const isSignedOut = !viewer;
const isSearching = searchQuery.length !== 0;
return ( return (
<> <>
@ -210,17 +159,7 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
</div> </div>
<div css={STYLES_MIDDLE}> <div css={STYLES_MIDDLE}>
{/**TODO: update Search component */} {/**TODO: update Search component */}
<Input <Search.Input viewer={viewer} data={data} onAction={onAction} page={page} />
containerStyle={{ height: "100%" }}
full
placeholder={`Search ${!viewer ? "slate.host" : ""}`}
inputCss={STYLES_SEARCH_COMPONENT}
onSubmit={handleSearch}
name="search"
{...getFieldProps()}
onChange={_handleInputChange}
value={state.query}
/>
</div> </div>
<Upload.Provider page={page} data={data} viewer={viewer}> <Upload.Provider page={page} data={data} viewer={viewer}>
<Upload.Root data={data}> <Upload.Root data={data}>
@ -238,7 +177,6 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
isSearching={isSearching} isSearching={isSearching}
isSignedOut={isSignedOut} isSignedOut={isSignedOut}
onAction={onAction} onAction={onAction}
onDismissSearch={handleDismissSearch}
/> />
</div> </div>
</Upload.Root> </Upload.Root>
@ -261,14 +199,14 @@ export default function ApplicationHeader({ viewer, page, data, onAction }) {
</div> </div>
<div css={STYLES_FILTER_NAVBAR}> <div css={STYLES_FILTER_NAVBAR}>
<Show when={!!viewer}> <Show when={!!viewer}>
<Filter.Navbar /> <FilterNavbar />
</Show> </Show>
</div> </div>
</> </>
); );
} }
const UserActions = ({ uploadAction, isSignedOut, isSearching, onAction, onDismissSearch }) => { const UserActions = ({ uploadAction, isSignedOut, isSearching, onAction }) => {
const authActions = React.useMemo( const authActions = React.useMemo(
() => ( () => (
<> <>
@ -316,13 +254,7 @@ const UserActions = ({ uploadAction, isSignedOut, isSearching, onAction, onDismi
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ y: -10, opacity: 0 }} exit={{ y: -10, opacity: 0 }}
> >
<button <Search.Dismiss style={{ marginLeft: 4 }} />
onClick={onDismissSearch}
style={{ marginRight: 4 }}
css={STYLES_DISMISS_BUTTON}
>
<SVG.Dismiss style={{ display: "block" }} height={16} width={16} />
</button>
</motion.div> </motion.div>
</Match> </Match>
</Switch> </Switch>

View File

@ -118,9 +118,10 @@ export default function CollectionPreview({ collection, viewer, owner, onAction
const title = collection.name || collection.slatename; const title = collection.name || collection.slatename;
const isOwner = viewer?.id === collection.ownerId; const isOwner = viewer?.id === collection.ownerId;
const preview = React.useMemo(() => getObjectToPreview(collection.coverImage), [ const preview = React.useMemo(
collection.coverImage, () => getObjectToPreview(collection.coverImage),
]); [collection.coverImage]
);
return ( return (
<div css={STYLES_CONTAINER}> <div css={STYLES_CONTAINER}>
@ -228,7 +229,7 @@ function Metrics({ fileCount, owner, isOwner, onAction }) {
</div> </div>
<div style={{ alignItems: "end" }} css={Styles.CONTAINER_CENTERED}> <div style={{ alignItems: "end" }} css={Styles.CONTAINER_CENTERED}>
{!isOwner && ( {isOwner && (
<> <>
<Link <Link
href={`/$/user/${owner.id}`} href={`/$/user/${owner.id}`}

View File

@ -1,63 +0,0 @@
import * as React from "react";
import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import { useFilterContext } from "~/components/core/Filter/Provider";
import { css } from "@emotion/react";
import { FILTER_VIEWS_IDS, FILTER_TYPES } from "~/common/filter-utilities";
import { Show } from "~/components/utility/Show";
const STYLES_BREADCRUMB_BUTTON = (theme) => css`
${Styles.BUTTON_RESET};
:hover {
color: ${theme.semantic.textBlack};
}
`;
function Item({ children, color, includeDelimiter, ...props }) {
return (
<>
{includeDelimiter && (
<System.P2 as="span" color="textGray">
{" "}
/{" "}
</System.P2>
)}
<button css={STYLES_BREADCRUMB_BUTTON} {...props}>
<System.P2 color={color}>{children}</System.P2>
</button>
</>
);
}
export function Breadcrumb(props) {
const [{ filterState }, { setFilterType, resetFilterState }] = useFilterContext();
const isCurrentViewInitial = filterState.view === FILTER_VIEWS_IDS.initial;
const changeFilterToBrowerView = () =>
setFilterType({
view: FILTER_VIEWS_IDS.browser,
type: FILTER_TYPES[FILTER_VIEWS_IDS.browser].filters.initial,
});
return (
<div {...props}>
<Show when={!isCurrentViewInitial}>
<Item onClick={resetFilterState} color="textGray">
All
</Item>
<Item
includeDelimiter
color={filterState.subview ? "textGray" : "textBlack"}
onClick={changeFilterToBrowerView}
>
{Strings.capitalize(filterState.view)}
</Item>
</Show>
<Show when={filterState.subview}>
<Item includeDelimiter>{Strings.capitalize(filterState.subview)}</Item>
</Show>
</div>
);
}

View File

@ -1,43 +0,0 @@
import * as React from "react";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
import { css } from "@emotion/react";
import DataView from "~/components/core/DataView";
import EmptyState from "~/components/core/EmptyState";
import { useFilterContext } from "~/components/core/Filter/Provider";
const STYLES_DATAVIEWER_WRAPPER = (theme) => css`
width: 100%;
min-height: 100vh;
padding: calc(20px + ${theme.sizes.filterNavbar}px) 24px 44px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 31px 16px 44px;
}
`;
export function Content({ viewer, onAction, page, ...props }) {
const [{ filterState }] = useFilterContext();
const { objects } = filterState;
return (
<div css={STYLES_DATAVIEWER_WRAPPER} {...props}>
{objects.length ? (
<DataView
key="scene-files-folder"
isOwner={true}
items={objects}
onAction={onAction}
viewer={viewer}
page={page}
view="grid"
/>
) : (
<EmptyState>
<FileTypeGroup />
<div style={{ marginTop: 24 }}>Drag and drop files into Slate to upload</div>
</EmptyState>
)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import * as Typography from "~/components/system/components/Typography";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { useFilterContext } from "~/components/core/Filter/Provider"; import { useFilterContext } from "~/components/core/Filter/Provider";
import { Link } from "~/components/core/Link";
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
* Shared components between filters * Shared components between filters
@ -19,7 +20,7 @@ const STYLES_FILTER_BUTTON = (theme) => css`
align-items: center; align-items: center;
width: 100%; width: 100%;
${Styles.BUTTON_RESET}; ${Styles.BUTTON_RESET};
padding: 4px 8px; padding: 5px 8px 3px;
border-radius: 8px; border-radius: 8px;
color: ${theme.semantic.textBlack}; color: ${theme.semantic.textBlack};
&:hover { &:hover {
@ -45,21 +46,21 @@ const STYLES_FILTERS_GROUP = css`
const FilterButton = ({ children, Icon, isSelected, ...props }) => ( const FilterButton = ({ children, Icon, isSelected, ...props }) => (
<li> <li>
<Typography.P2 <Link {...props}>
as="button" <span as="span" css={[STYLES_FILTER_BUTTON, isSelected && STYLES_FILTER_BUTTON_HIGHLIGHTED]}>
css={[STYLES_FILTER_BUTTON, isSelected && STYLES_FILTER_BUTTON_HIGHLIGHTED]} <Icon height={16} width={16} style={{ flexShrink: 0 }} />
{...props} <Typography.P2 as="span" nbrOflines={1} style={{ marginLeft: 6 }}>
> {children}
<Icon height={16} width={16} /> </Typography.P2>
<span style={{ marginLeft: 6 }}>{children}</span> </span>
</Typography.P2> </Link>
</li> </li>
); );
const FilterSection = ({ title, children, ...props }) => ( const FilterSection = ({ title, children, ...props }) => (
<div {...props}> <div {...props}>
{title && ( {title && (
<Typography.H6 style={{ paddingLeft: 8, paddingBottom: 4 }} color="textGray"> <Typography.H6 style={{ paddingLeft: 8, marginBottom: 4 }} color="textGray">
{title} {title}
</Typography.H6> </Typography.H6>
)} )}
@ -71,143 +72,47 @@ const FilterSection = ({ title, children, ...props }) => (
* InitialFilters * InitialFilters
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
function Initial({ filters, goToBrowserView }) { function Library({ page, onAction }) {
const [{ filterState }, { setFilterType, resetFilterState }] = useFilterContext(); const [, { hidePopup }] = useFilterContext();
const currentFilterType = filterState.type;
const currentFilterView = filterState.view;
const changeFilter = ({ type }) => setFilterType({ view: currentFilterView, type }); const isSelected = page.id === "NAV_DATA";
return ( return (
<> <>
{/** Breadcrumb All */}
<FilterSection> <FilterSection>
<FilterButton <FilterButton
href="/_/data"
isSelected={isSelected}
onAction={onAction}
Icon={SVG.Clock} Icon={SVG.Clock}
isSelected={currentFilterType === filters.library} onClick={hidePopup}
onClick={resetFilterState}
> >
My Library My Library
</FilterButton> </FilterButton>
</FilterSection> </FilterSection>
<FilterSection title="Connected" style={{ marginTop: 16 }}>
<FilterButton Icon={SVG.Layout} onClick={goToBrowserView}>
Browser
</FilterButton>
</FilterSection>
<FilterSection style={{ marginTop: 16 }} title="Types">
<FilterButton
Icon={SVG.Image}
isSelected={currentFilterType === filters.images}
onClick={() => changeFilter({ type: filters.images })}
>
Images
</FilterButton>
<FilterButton
Icon={SVG.Radio}
isSelected={currentFilterType === filters.audios}
onClick={() => changeFilter({ type: filters.audios })}
>
Audios
</FilterButton>
<FilterButton
Icon={SVG.Video}
isSelected={currentFilterType === filters.videos}
onClick={() => changeFilter({ type: filters.videos })}
>
Videos
</FilterButton>
<FilterButton
Icon={SVG.FileText}
isSelected={currentFilterType === filters.documents}
onClick={() => changeFilter({ type: filters.documents })}
>
Documents
</FilterButton>
</FilterSection>
</> </>
); );
} }
/* ------------------------------------------------------------------------------------------------- function Tags({ viewer, data, onAction, ...props }) {
* Browser Filters const [, { hidePopup }] = useFilterContext();
* -----------------------------------------------------------------------------------------------*/
function Browser({ filters, goToSavedSubview }) {
const [{ filterState }] = useFilterContext();
const currentFilterType = filterState.type;
return ( return (
<FilterSection> <FilterSection title="Tags" {...props}>
<FilterButton Icon={SVG.Clock} isSelected={currentFilterType === filters.all}> {viewer.slates.map((slate) => (
All <FilterButton
</FilterButton> key={slate.id}
<FilterButton disabled Icon={SVG.Clock}> href={`/$/slate/${slate.id}`}
History isSelected={slate.id === data?.id}
</FilterButton> onAction={onAction}
<FilterButton disabled Icon={SVG.Bookmark}> Icon={slate.isPublic ? SVG.Hash : SVG.SecurityLock}
Bookmarks onClick={hidePopup}
</FilterButton> >
<FilterButton Icon={SVG.FilePlus} onClick={goToSavedSubview}> {slate.slatename}
Saved </FilterButton>
</FilterButton> ))}
</FilterSection> </FilterSection>
); );
} }
const BrowserSaved = ({ filters }) => { export { Library, Tags };
const [{ filterState }, { setFilterType }] = useFilterContext();
const currentFilterType = filterState.type;
const changeSavedFilterType = (type) =>
setFilterType({ view: filterState.view, subview: filterState.subview, type });
return (
<FilterSection>
<FilterButton
Icon={SVG.Clock}
isSelected={currentFilterType === filters.all}
onClick={() => changeSavedFilterType(filters.all)}
>
All
</FilterButton>
<FilterButton
Icon={SVG.Twitter}
isSelected={currentFilterType === filters.twitter}
onClick={() => changeSavedFilterType(filters.twitter)}
>
Twitter
</FilterButton>
<FilterButton
Icon={SVG.Youtube}
isSelected={currentFilterType === filters.youtube}
onClick={() => changeSavedFilterType(filters.youtube)}
>
Youtube
</FilterButton>
<FilterButton
Icon={SVG.Github}
isSelected={currentFilterType === filters.github}
onClick={() => changeSavedFilterType(filters.github)}
>
Github
</FilterButton>
<FilterButton
Icon={SVG.Twitch}
isSelected={currentFilterType === filters.twitch}
onClick={() => changeSavedFilterType(filters.twitch)}
>
Twitch
</FilterButton>
<FilterButton
Icon={SVG.Instagram}
isSelected={currentFilterType === filters.instagram}
onClick={() => changeSavedFilterType(filters.instagram)}
>
Instagram
</FilterButton>
</FilterSection>
);
};
export { Initial, Browser, BrowserSaved };

View File

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import * as Styles from "~/common/styles";
import { usePortals } from "~/components/core/PortalsProvider"; import { usePortals } from "~/components/core/PortalsProvider";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
@ -16,8 +17,9 @@ export function NavbarPortal({ children }) {
return filterNavbarElement return filterNavbarElement
? ReactDOM.createPortal( ? ReactDOM.createPortal(
<> <>
<Divider height="0.5px" /> <Divider height="0.5px" color="borderGrayLight" css={Styles.MOBILE_HIDDEN} />
<div css={STYLES_NAVBAR}>{children}</div> <div css={STYLES_NAVBAR}>{children}</div>
<Divider height="0.5px" color="borderGrayLight" css={Styles.MOBILE_ONLY} />
</>, </>,
filterNavbarElement filterNavbarElement
) )
@ -29,12 +31,27 @@ export function NavbarPortal({ children }) {
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
const STYLES_NAVBAR = (theme) => css` const STYLES_NAVBAR = (theme) => css`
position: relative;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: ${theme.semantic.bgWhite};
padding: 9px 24px 11px; padding: 9px 24px 11px;
box-shadow: ${theme.shadow.lightSmall}; box-shadow: ${theme.shadow.lightSmall};
@media (max-width: ${theme.sizes.mobile}px) {
padding: 9px 16px 11px;
}
`;
const STYLES_NAVBAR_BACKGROUND = (theme) => css`
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
z-index: -1;
background-color: ${theme.semantic.bgWhite};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) { @supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
-webkit-backdrop-filter: blur(75px); -webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px); backdrop-filter: blur(75px);
@ -49,5 +66,10 @@ const STYLES_NAVBAR = (theme) => css`
export function Navbar({ children }) { export function Navbar({ children }) {
const { filterNavbar } = usePortals(); const { filterNavbar } = usePortals();
const [, setFilterElement] = filterNavbar; const [, setFilterElement] = filterNavbar;
return <div ref={setFilterElement}>{children}</div>; return (
<div ref={setFilterElement}>
{children}
<div css={STYLES_NAVBAR_BACKGROUND} />
</div>
);
} }

View File

@ -0,0 +1,86 @@
import * as React from "react";
import * as SVG from "~/common/svg";
import * as Styles from "~/common/styles";
import * as Filters from "~/components/core/Filter/Filters";
import { useFilterContext } from "~/components/core/Filter/Provider";
import { motion } from "framer-motion";
import { css } from "@emotion/react";
/* -------------------------------------------------------------------------------------------------
* Popup trigger
* -----------------------------------------------------------------------------------------------*/
export function PopupTrigger({ children, isMobile, ...props }) {
const [{ sidebarState, popupState }, { hidePopup, togglePopup }] = useFilterContext();
React.useEffect(() => {
if (sidebarState.isVisible) {
hidePopup();
}
}, [sidebarState.isVisible]);
if (sidebarState.isVisible && !isMobile) return null;
return (
<button
onClick={togglePopup}
css={[Styles.BUTTON_RESET, Styles.HORIZONTAL_CONTAINER_CENTERED]}
{...props}
>
{children}
<motion.div initial={null} animate={{ rotateX: popupState.isVisible ? 0 : 180 }}>
<SVG.ChevronUp style={{ display: "block" }} />
</motion.div>
</button>
);
}
/* -------------------------------------------------------------------------------------------------
* Popup
* -----------------------------------------------------------------------------------------------*/
const STYLES_SIDEBAR_FILTER_WRAPPER = (theme) => css`
position: sticky;
top: ${theme.sizes.header + theme.sizes.filterNavbar}px;
width: 236px;
max-height: 420px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
padding: 20px 16px;
box-shadow: ${theme.shadow.lightLarge};
border: 1px solid ${theme.semantic.borderGrayLight};
background-color: ${theme.semantic.bgLight};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background-color: ${theme.semantic.bgBlurWhite};
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
@media (max-width: ${theme.sizes.mobile}px) {
border-radius: unset;
width: 100%;
max-height: 375px;
padding: 15px 8px;
}
`;
export function Popup({ viewer, onAction, css, data, page, ...props }) {
const [{ popupState }] = useFilterContext();
if (!popupState.isVisible) return null;
return (
<div css={[STYLES_SIDEBAR_FILTER_WRAPPER, css]} {...props}>
<Filters.Library page={page} onAction={onAction} />
<Filters.Tags
viewer={viewer}
data={data}
page={page}
onAction={onAction}
style={{ marginTop: 12 }}
/>
</div>
);
}

View File

@ -1,26 +1,22 @@
import * as React from "react"; import * as React from "react";
import { useWorker } from "~/common/hooks";
const UploadContext = React.createContext({}); const UploadContext = React.createContext({});
export const useFilterContext = () => React.useContext(UploadContext); export const useFilterContext = () => React.useContext(UploadContext);
export const Provider = ({ children, viewer }) => { export const Provider = ({ children }) => {
const [isSidebarVisible, toggleSidebar] = useFilterSidebar(); const [isSidebarVisible, toggleSidebar] = useFilterSidebar();
const [filterState, { setFilterType, setFilterObjects, resetFilterState }] = useFilter({ const [isPopupVisible, { hidePopup, togglePopup }] = useFilterPopup();
library: viewer.library,
});
const workerState = useFilterWorker({ filterState, setFilterObjects, library: viewer.library });
const contextValue = React.useMemo( const contextValue = React.useMemo(
() => [ () => [
{ isSidebarVisible, filterState, ...workerState }, {
{ toggleSidebar, setFilterType, resetFilterState }, sidebarState: { isVisible: isSidebarVisible },
popupState: { isVisible: isPopupVisible },
},
{ toggleSidebar, hidePopup, togglePopup },
], ],
[isSidebarVisible, filterState, workerState] [isSidebarVisible, isPopupVisible]
); );
return <UploadContext.Provider value={contextValue}>{children}</UploadContext.Provider>; return <UploadContext.Provider value={contextValue}>{children}</UploadContext.Provider>;
@ -32,61 +28,10 @@ const useFilterSidebar = () => {
return [isSidebarVisible, toggleSidebar]; return [isSidebarVisible, toggleSidebar];
}; };
const useFilter = ({ library }) => { const useFilterPopup = () => {
const DEFAULT_STATE = { const [isPopupVisible, setPopupVisibility] = React.useState(false);
view: "initial",
subview: undefined,
type: "library",
objects: library,
search: {
objects: [],
tags: [],
startDate: null,
endDate: null,
},
};
const [filterState, setFilterState] = React.useState(DEFAULT_STATE); const hidePopup = () => setPopupVisibility(false);
const togglePopup = () => setPopupVisibility((prev) => !prev);
const setFilterType = ({ view, subview = undefined, type }) => return [isPopupVisible, { hidePopup, togglePopup }];
setFilterState((prev) => ({ ...prev, view, subview, type }));
const setFilterObjects = (objects) => setFilterState((prev) => ({ ...prev, objects }));
const resetFilterState = () => setFilterState(DEFAULT_STATE);
return [filterState, { setFilterType, resetFilterState, setFilterObjects }];
};
const useFilterWorker = ({ filterState, setFilterObjects, library }) => {
const DEFAULT_STATE = { loading: false, error: false };
const [workerState, setWorkerState] = React.useState(DEFAULT_STATE);
const { view, subview, type } = filterState;
/**
* NOTE(amine): Web workers are usually pretty fast,
* but if it takes more than 500ms to handle a task, we'll show a loading screen
*/
const timeoutRef = React.useRef();
useWorker(
{
onStart: (worker) => {
worker.postMessage({ objects: library, view, subview, type });
timeoutRef.current = setTimeout(() => {
setWorkerState((prev) => ({ ...prev, loading: true }));
}, 500);
},
onError: () => setWorkerState((prev) => ({ ...prev, error: true })),
onMessage: (e) => {
clearTimeout(timeoutRef.current);
setWorkerState(DEFAULT_STATE);
setFilterObjects(e.data);
},
},
[view, subview, library, type]
);
return workerState;
}; };

View File

@ -2,10 +2,9 @@ import * as React from "react";
import * as SVG from "~/common/svg"; import * as SVG from "~/common/svg";
import * as Styles from "~/common/styles"; import * as Styles from "~/common/styles";
import * as Filters from "~/components/core/Filter/Filters"; import * as Filters from "~/components/core/Filter/Filters";
import * as Constants from "~/common/constants";
import * as FilterUtilities from "~/common/filter-utilities";
import { useFilterContext } from "~/components/core/Filter/Provider"; import { useFilterContext } from "~/components/core/Filter/Provider";
import { Show } from "~/components/utility/Show";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
@ -23,19 +22,16 @@ const STYLES_SIDEBAR_TRIGGER = (theme) => css`
} }
`; `;
export function SidebarTrigger() { export function SidebarTrigger({ css }) {
const [{ isSidebarVisible }, { toggleSidebar }] = useFilterContext(); const [{ sidebarState }, { toggleSidebar }] = useFilterContext();
return ( return (
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
css={[ css={[STYLES_SIDEBAR_TRIGGER, css]}
STYLES_SIDEBAR_TRIGGER, style={{
(theme) => backgroundColor: sidebarState.isVisible ? Constants.semantic.bgGrayLight : "unset",
css({ color: sidebarState.isVisible ? Constants.semantic.textBlack : Constants.semantic.textGray,
backgroundColor: isSidebarVisible ? theme.semantic.bgGrayLight : "none", }}
color: isSidebarVisible ? theme.semantic.textBlack : theme.semantic.textGray,
}),
]}
> >
<SVG.Sidebar style={{ display: "block" }} /> <SVG.Sidebar style={{ display: "block" }} />
</button> </button>
@ -46,24 +42,13 @@ export function SidebarTrigger() {
* Sidebar * Sidebar
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
export function Sidebar() {
const [{ isSidebarVisible }] = useFilterContext();
return (
<Show when={isSidebarVisible}>
<div css={STYLES_SIDEBAR_FILTER_WRAPPER}>
<SidebarContent />
</div>
</Show>
);
}
const STYLES_SIDEBAR_FILTER_WRAPPER = (theme) => css` const STYLES_SIDEBAR_FILTER_WRAPPER = (theme) => css`
position: sticky; position: sticky;
top: ${theme.sizes.header + theme.sizes.filterNavbar}px; top: ${theme.sizes.header + theme.sizes.filterNavbar}px;
width: 236px; width: 300px;
height: 100vh;
max-height: calc(100vh - ${theme.sizes.header + theme.sizes.filterNavbar}px); max-height: calc(100vh - ${theme.sizes.header + theme.sizes.filterNavbar}px);
padding: 20px; overflow-y: auto;
padding: 20px 24px calc(16px + ${theme.sizes.intercomWidget}px + ${theme.sizes.filterNavbar}px);
background-color: ${theme.semantic.bgLight}; background-color: ${theme.semantic.bgLight};
@media (max-width: ${theme.sizes.mobile}px) { @media (max-width: ${theme.sizes.mobile}px) {
@ -71,46 +56,21 @@ const STYLES_SIDEBAR_FILTER_WRAPPER = (theme) => css`
} }
`; `;
/* ------------------------------------------------------------------------------------------------- export function Sidebar({ viewer, onAction, data, page, isMobile }) {
* SidebarContent const [{ sidebarState }] = useFilterContext();
* -----------------------------------------------------------------------------------------------*/
function SidebarContent() { if (!sidebarState.isVisible || isMobile) return null;
const [{ filterState }, { setFilterType }] = useFilterContext();
const currentView = filterState.view;
const currentSubview = filterState.subview;
const { FILTER_VIEWS_IDS, FILTER_SUBVIEWS_IDS } = FilterUtilities;
const { filters, subviews } = FilterUtilities.getViewData(currentView);
const changeFilterView = (view) => {
const { filters } = FilterUtilities.getViewData(view);
setFilterType({ view: view, type: filters.initial });
};
const changeFilterSubview = (subview) => {
const initialType = subviews[subview].filters.initial;
setFilterType({ view: currentView, subview, type: initialType });
};
if (currentView === FILTER_VIEWS_IDS.browser) {
if (currentSubview === FILTER_SUBVIEWS_IDS.browser.saved) {
const { filters } = subviews[currentSubview];
return <Filters.BrowserSaved filters={filters} />;
}
return (
<Filters.Browser
filters={filters}
goToSavedSubview={() => changeFilterSubview(FILTER_SUBVIEWS_IDS.browser.saved)}
/>
);
}
return ( return (
<Filters.Initial <div css={STYLES_SIDEBAR_FILTER_WRAPPER}>
filters={filters} <Filters.Library page={page} onAction={onAction} />
goToBrowserView={() => changeFilterView(FILTER_VIEWS_IDS.browser)} <Filters.Tags
/> page={page}
onAction={onAction}
data={data}
viewer={viewer}
style={{ marginTop: 12 }}
/>
</div>
); );
} }

View File

@ -1,35 +1,124 @@
import * as React from "react"; import * as React from "react";
import * as System from "~/components/system"; import * as System from "~/components/system";
import * as Styles from "~/common/styles";
import * as Search from "~/components/core/Search";
import { Navbar, NavbarPortal } from "~/components/core/Filter/Navbar"; import { css } from "@emotion/react";
import { Breadcrumb } from "~/components/core/Filter/Breadcrumb"; import { NavbarPortal } from "~/components/core/Filter/Navbar";
import { Provider } from "~/components/core/Filter/Provider"; import { Provider } from "~/components/core/Filter/Provider";
import { Sidebar, SidebarTrigger } from "~/components/core/Filter/Sidebar"; import { Sidebar, SidebarTrigger } from "~/components/core/Filter/Sidebar";
import { Content } from "~/components/core/Filter/Content"; import { Popup, PopupTrigger } from "~/components/core/Filter/Popup";
import { useSearchStore } from "~/components/core/Search/store";
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
* Title * Title
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
function Title() { function Title({ page, data }) {
return <System.H5 color="textBlack">All</System.H5>; const { query, isSearching } = useSearchStore();
let title = React.useMemo(() => {
if (isSearching) return `Searching for ${query}`;
if (page.id === "NAV_DATA") return "My library";
if (page.id === "NAV_SLATE" && data?.slatename) return "# " + data?.slatename;
}, [page, data, query, isSearching]);
return (
<System.H5 title={title} style={{ maxWidth: 400 }} as="p" nbrOflines={1} color="textBlack">
{title}
</System.H5>
);
} }
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
* Actions * Actions
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
function Actions() { function Actions() {
return <div />; return <div />;
} }
export { const STYLES_FILTER_TITLE_WRAPPER = css`
Title, ${Styles.MOBILE_HIDDEN};
Actions, position: absolute;
Sidebar, top: 50%;
SidebarTrigger, left: 50%;
Provider, transform: translate(-50%, -50%);
Navbar, `;
Content,
Breadcrumb, const STYLES_FILTER_POPUP_WRAPPER = (theme) => css`
NavbarPortal, position: absolute;
}; top: calc(${theme.sizes.filterNavbar}px + 4px);
left: 4px;
width: 100%;
@media (max-width: ${theme.sizes.mobile}px) {
top: ${theme.sizes.filterNavbar}px;
left: 0px;
}
`;
/* -------------------------------------------------------------------------------------------------
* Filter
* -----------------------------------------------------------------------------------------------*/
const STYLES_FILTER_CONTENT = (theme) => css`
${Styles.HORIZONTAL_CONTAINER};
width: 100%;
margin-top: ${theme.sizes.filterNavbar}px;
`;
export default function Filter({ isActive, viewer, onAction, page, data, isMobile, children }) {
const { results, isSearching } = useSearchStore();
const showSearchResult = isSearching && !!results;
if (!isActive) {
return showSearchResult ? (
<Search.Content viewer={viewer} page={page} onAction={onAction} />
) : (
children
);
}
return (
<>
<Provider>
<NavbarPortal>
<div css={STYLES_FILTER_POPUP_WRAPPER}>
<Popup viewer={viewer} onAction={onAction} data={data} page={page} />
</div>
<div css={Styles.CONTAINER_CENTERED}>
<div css={Styles.MOBILE_HIDDEN}>
<SidebarTrigger />
</div>
<PopupTrigger isMobile={isMobile} style={{ marginLeft: 2 }}>
<span css={Styles.MOBILE_ONLY} style={{ marginRight: 8 }}>
<Title page={page} data={data} />
</span>
</PopupTrigger>
</div>
<div css={STYLES_FILTER_TITLE_WRAPPER}>
<Title page={page} data={data} />
</div>
<Actions />
</NavbarPortal>
<div css={STYLES_FILTER_CONTENT}>
<Sidebar
viewer={viewer}
onAction={onAction}
data={data}
page={page}
isMobile={isMobile}
/>
<div style={{ flexGrow: 1 }}>
{showSearchResult ? (
<Search.Content viewer={viewer} page={page} onAction={onAction} />
) : (
children
)}
</div>
</div>
</Provider>
</>
);
}

View File

@ -6,6 +6,7 @@ import * as Strings from "~/common/strings";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { Markdown } from "~/components/system/components/Markdown"; import { Markdown } from "~/components/system/components/Markdown";
import { H1, H2, H3, H4, P1, UL, OL, LI, A } from "~/components/system/components/Typography"; import { H1, H2, H3, H4, P1, UL, OL, LI, A } from "~/components/system/components/Typography";
import { useCache, useIsomorphicLayoutEffect } from "~/common/hooks";
const STYLES_ASSET = (theme) => css` const STYLES_ASSET = (theme) => css`
padding: 120px calc(32px + 16px + 8px); padding: 120px calc(32px + 16px + 8px);
@ -133,12 +134,18 @@ const STYLES_INTENT = (theme) => css`
); );
`; `;
export default function MarkdownFrame({ url, date }) { export default function MarkdownFrame({ cid, url, date }) {
const [content, setContent] = React.useState(""); const [cache, setCache] = useCache();
const cachedContent = cache[cid] || "";
const [content, setContent] = React.useState(cachedContent);
useIsomorphicLayoutEffect(() => {
if (cachedContent) return;
React.useEffect(() => {
fetch(url).then(async (res) => { fetch(url).then(async (res) => {
const content = await res.text(); const content = await res.text();
setCache(content);
setContent(content); setContent(content);
}); });
}, []); }, []);

View File

@ -6,8 +6,10 @@ import { AspectRatio } from "~/components/system";
import { useInView } from "~/common/hooks"; import { useInView } from "~/common/hooks";
import { Blurhash } from "react-blurhash"; import { Blurhash } from "react-blurhash";
import { isBlurhashValid } from "blurhash"; import { isBlurhashValid } from "blurhash";
import { AnimatePresence, motion } from "framer-motion";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { useCache } from "~/common/hooks";
import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive"; import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive";
const STYLES_PLACEHOLDER_ABSOLUTE = css` const STYLES_PLACEHOLDER_ABSOLUTE = css`
@ -29,8 +31,8 @@ const STYLES_IMAGE = css`
width: 100%; width: 100%;
`; `;
const ImagePlaceholder = ({ blurhash }) => ( const ImagePlaceholder = ({ blurhash, ...props }) => (
<div css={STYLES_PLACEHOLDER_ABSOLUTE}> <motion.div css={STYLES_PLACEHOLDER_ABSOLUTE} {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}> <div css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
<AspectRatio ratio={1}> <AspectRatio ratio={1}>
<div> <div>
@ -45,12 +47,9 @@ const ImagePlaceholder = ({ blurhash }) => (
</div> </div>
</AspectRatio> </AspectRatio>
</div> </div>
</div> </motion.div>
); );
// NOTE(amine): cache
const cidsLoaded = {};
export default function ImageObjectPreview({ export default function ImageObjectPreview({
url, url,
file, file,
@ -58,22 +57,20 @@ export default function ImageObjectPreview({
tag, tag,
...props ...props
}) { }) {
const isCached = cidsLoaded[file.cid]; /** NOTE(amine): To minimize the network load, we only load images when they're in view.
This creates an issue where cached images will reload each time they came to view.
To prevent reloading we'll keep track of the images that already loaded */
const [cache, setCache] = useCache();
const isCached = cache[file.cid];
const previewerRef = React.useRef(); const previewerRef = React.useRef();
const [isLoading, setLoading] = React.useState(isCached);
const handleOnLoaded = () => {
cidsLoaded[file.cid] = true;
setLoading(false);
};
const { isInView } = useInView({ const { isInView } = useInView({
ref: previewerRef, ref: previewerRef,
}); });
const { type, coverImage } = file; const { type, coverImage } = file;
const imgTag = type.split("/")[1]; const imgTag = type.split("/")[1];
const imageUrl = coverImage ? coverImage?.url || Strings.getURLfromCID(coverImage?.cid) : url;
const blurhash = React.useMemo(() => { const blurhash = React.useMemo(() => {
return file.blurhash && isBlurhashValid(file.blurhash).result return file.blurhash && isBlurhashValid(file.blurhash).result
? file.blurhash ? file.blurhash
@ -82,14 +79,14 @@ export default function ImageObjectPreview({
: null; : null;
}, [file]); }, [file]);
const shouldShowPlaceholder = isLoading && blurhash; const [isLoading, setLoading] = React.useState(true);
const handleOnLoaded = () => (setCache({ key: file.cid, value: true }), setLoading(false));
const imageUrl = coverImage ? coverImage?.url || Strings.getURLfromCID(coverImage?.cid) : url; const shouldShowPlaceholder = !isCached && isLoading && !!blurhash;
return ( return (
<ObjectPreviewPrimitive file={file} tag={tag || imgTag} isImage {...props}> <ObjectPreviewPrimitive file={file} tag={tag || imgTag} isImage {...props}>
<div ref={previewerRef} css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}> <div ref={previewerRef} css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
{isInView && ( {(isCached || isInView) && (
<img <img
css={STYLES_IMAGE} css={STYLES_IMAGE}
src={imageUrl} src={imageUrl}
@ -97,7 +94,11 @@ export default function ImageObjectPreview({
onLoad={handleOnLoaded} onLoad={handleOnLoaded}
/> />
)} )}
{shouldShowPlaceholder && <ImagePlaceholder blurhash={blurhash} />} <AnimatePresence>
{shouldShowPlaceholder && (
<ImagePlaceholder blurhash={blurhash} initial={{ opacity: 1 }} exit={{ opacity: 0 }} />
)}
</AnimatePresence>
</div> </div>
</ObjectPreviewPrimitive> </ObjectPreviewPrimitive>
); );

View File

@ -5,7 +5,7 @@ import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities"; import * as Utilities from "~/common/utilities";
import { P3 } from "~/components/system"; import { P3 } from "~/components/system";
import { useIsomorphicLayoutEffect } from "~/common/hooks"; import { useCache, useIsomorphicLayoutEffect } from "~/common/hooks";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File"; import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
@ -29,12 +29,21 @@ const STYLES_TEXT_PREVIEW = (theme) =>
}); });
export default function TextObjectPreview({ url, file, ...props }) { export default function TextObjectPreview({ url, file, ...props }) {
const [{ content, error }, setState] = React.useState({ content: "", error: undefined }); const [cache, setCache] = useCache();
const cachedContent = cache[file.cid] || "";
const [{ content, error }, setState] = React.useState({
content: cachedContent,
error: undefined,
});
useIsomorphicLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (cachedContent) return;
fetch(url) fetch(url)
.then(async (res) => { .then(async (res) => {
const content = await res.text(); const content = await res.text();
setCache({ key: file.cid, value: content });
setState({ content }); setState({ content });
}) })
.catch((e) => { .catch((e) => {

View File

@ -0,0 +1,231 @@
import * as React from "react";
import * as SVG from "~/common/svg";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { Input as InputPrimitive } from "~/components/system/components/Input";
import { useSearchStore } from "~/components/core/Search/store";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
import { Link } from "~/components/core/Link";
import DataView from "~/components/core/DataView";
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
import EmptyState from "~/components/core/EmptyState";
import omit from "lodash.omit";
/* -------------------------------------------------------------------------------------------------
* Input
* -----------------------------------------------------------------------------------------------*/
const STYLES_SEARCH_COMPONENT = (theme) => css`
background-color: transparent;
box-shadow: none;
height: 100%;
input {
height: 100%;
padding: 0px 4px;
border-radius: 0px;
}
&::placeholder {
color: ${theme.semantic.textGray};
}
`;
const useSearchViaParams = ({ params, handleSearch }) => {
const { setQuery, clearSearch } = useSearchStore();
React.useEffect(() => {
if (params?.s) {
setQuery(params.s);
handleSearch(params.s);
}
}, []);
// NOTE(amine): if we change
React.useEffect(() => {
if (!params.s) clearSearch();
}, [params.s]);
};
const useDebouncedSearch = ({ handleSearch }) => {
const { query } = useSearchStore();
const timeRef = React.useRef();
React.useEffect(() => {
timeRef.current = setTimeout(() => handleSearch(query), 300);
return () => clearTimeout(timeRef.current);
}, [query]);
};
function Input({ viewer, data, page, onAction }) {
const { search, query, isFetchingResults, setQuery } = useSearchStore();
const handleSearch = async (query) => {
// NOTE(amine): update params with search query
onAction({
type: "UPDATE_PARAMS",
params: query?.length > 0 ? { s: query } : omit(page.params, ["s"]),
});
if (!query) return;
// NOTE(amine): searching on your own tag.
if (page.id === "NAV_SLATE" && data?.ownerId === viewer?.id) {
search({
types: ["FILE"],
tagIds: [data.id],
query,
});
return;
}
//NOTE(amine): searching on another user's tag
if (page.id === "NAV_SLATE" && data?.ownerId !== viewer?.id) {
search({
types: ["FILE"],
tagIds: [data.id],
query,
globalSearch: true,
});
return;
}
//NOTE(amine): searching on another user's profile
if (page.id === "NAV_PROFILE" && data?.id !== viewer?.id) {
console.log(data?.id, page.id, page.id === "NAV_PROFILE", data?.id !== viewer?.id);
search({
types: ["SLATE", "FILE"],
userId: data.id,
query,
globalSearch: true,
grouped: true,
});
return;
}
//NOTE(amine): searching on library
if (viewer) {
search({
types: ["FILE", "SLATE"],
query,
grouped: true,
});
return;
}
// NOTE(amine): global search
search({
types: ["FILE", "SLATE", "USER"],
globalSearch: true,
query: query,
grouped: true,
});
};
useSearchViaParams({ params: page.params, onAction, handleSearch });
useDebouncedSearch({ handleSearch });
return (
<div style={{ position: "relative" }}>
<InputPrimitive
full
containerStyle={{ height: "100%" }}
inputCss={STYLES_SEARCH_COMPONENT}
name="search"
placeholder={`Search ${!viewer ? "slate.host" : ""}`}
onSubmit={() => handleSearch(query)}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isFetchingResults && (
<div style={{ position: "absolute", right: 0, top: 0 }}>
<LoaderSpinner style={{ position: "block", height: 16, width: 16 }} />
</div>
)}
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* Dismiss
* -----------------------------------------------------------------------------------------------*/
const STYLES_DISMISS_BUTTON = (theme) => css`
display: block;
${Styles.BUTTON_RESET};
color: ${theme.semantic.textGray};
`;
function Dismiss({ css, ...props }) {
const { clearSearch } = useSearchStore();
return (
<button onClick={clearSearch} css={[STYLES_DISMISS_BUTTON, css]} {...props}>
<SVG.Dismiss style={{ display: "block" }} height={16} width={16} />
</button>
);
}
/* -------------------------------------------------------------------------------------------------
* Content
* -----------------------------------------------------------------------------------------------*/
const STYLES_SEARCH_CONTENT = (theme) => css`
width: 100%;
min-height: 100vh;
padding: calc(20px + ${theme.sizes.filterNavbar}px) 24px 44px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: calc(31px + ${theme.sizes.filterNavbar}px) 16px 44px;
}
`;
function Content({ onAction, viewer, page }) {
const { results } = useSearchStore();
const { files, slates } = results;
if (results.files.length === 0 && results.slates.length === 0) {
return (
<div css={STYLES_SEARCH_CONTENT}>
<EmptyState>
<FileTypeGroup />
<div style={{ marginTop: 24 }}>Sorry we couldn&apos;t find any results.</div>
</EmptyState>
</div>
);
}
return (
<div css={STYLES_SEARCH_CONTENT}>
<DataView
key="scene-files-folder"
isOwner={true}
items={files}
onAction={onAction}
viewer={viewer}
page={page}
view="grid"
/>
{slates.length > 0 ? (
<div style={{ marginTop: 24 }} css={Styles.COLLECTIONS_PREVIEW_GRID}>
{slates.map((slate) => (
<Link key={slate.id} href={`/$/slate/${slate.id}`} onAction={onAction}>
<CollectionPreviewBlock
key={slate.id}
collection={slate}
viewer={viewer}
// TODO(amine): use owner's info instead of viewer
owner={viewer}
onAction={onAction}
/>
</Link>
))}
</div>
) : null}
</div>
);
}
export { Input, Dismiss, Content };

View File

@ -0,0 +1,48 @@
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import create from "zustand";
const DEFAULT_STATE = {
query: "",
results: null,
isSearching: false,
isFetchingResults: false,
};
export const useSearchStore = create((set) => {
const search = async ({ types, query, globalSearch, tagIds, grouped }) => {
//for more context, look at node_common/managers/search/search.js
//userId: (optional) the id of the user whose stuff we are searching through. If specified, globalSearch is disregarded since the search will be limited to that user's public items. Does not apply when searching for type USER
//types: leaving it null searches everything. Doing ["SLATE"] searches just slates, doing ["USER", "FILE"] searches users and files.
//globalSearch: whether you are just searching the user's files/slates or global files/slates. This option doesn't exist for searching users since there is no notion of public or private users
//tagIds: only applies when searching files. the ids of the tags (aka collections) you are searching within. aka if you only want to search for files in a given slate, provide that slate's id. If no tag ids are provided, it searches all files
//grouped: whether to group the results by type (slate, user, file) when searching multiple types. Doesn't apply when searching only one type e.g. types: ["SLATE"]
set((prev) => ({ ...prev, isFetchingResults: true }));
const response = await Actions.search({
types,
query,
globalSearch,
tagIds,
grouped,
});
Events.hasError(response);
const { results = [] } = response;
const files = results?.files || results;
const slates = results?.slates || [];
set((prev) => ({ ...prev, results: { files, slates }, isFetchingResults: false }));
};
const clearSearch = () => set((prev) => ({ ...prev, ...DEFAULT_STATE }));
const setQuery = (query) => set((prev) => ({ ...prev, isSearching: true, query }));
return {
...DEFAULT_STATE,
search,
setQuery,
clearSearch,
};
});

View File

@ -198,7 +198,7 @@ export default class SlateMediaObject extends React.Component {
} }
if (Validations.isMarkdown(file.filename, type)) { if (Validations.isMarkdown(file.filename, type)) {
return <MarkdownFrame date={file.createdAt} url={url} />; return <MarkdownFrame date={file.createdAt} cid={file?.cid} url={url} />;
} }
if (Validations.isPreviewableImage(type)) { if (Validations.isPreviewableImage(type)) {

View File

@ -6,16 +6,20 @@ export const Divider = ({
width = "100%", width = "100%",
height = "0.5px", height = "0.5px",
color = Constants.system.grayLight4, color = Constants.system.grayLight4,
css,
...props ...props
}) => { }) => {
return ( return (
<div <div
css={(theme) => ({ css={[
height, (theme) => ({
width, height,
minHeight: height, width,
backgroundColor: theme.system?.[color] || theme.semantic?.[color] || color, minHeight: height,
})} backgroundColor: theme.system?.[color] || theme.semantic?.[color] || color,
}),
css,
]}
{...props} {...props}
/> />
); );

View File

@ -1,13 +1,15 @@
import * as React from "react"; import * as React from "react";
import * as Constants from "~/common/constants"; import * as Constants from "~/common/constants";
import * as Filter from "~/components/core/Filter";
import * as Styles from "~/common/styles"; import * as Styles from "~/common/styles";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { GlobalCarousel } from "~/components/system/components/GlobalCarousel"; import { GlobalCarousel } from "~/components/system/components/GlobalCarousel";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
import ScenePage from "~/components/core/ScenePage"; import ScenePage from "~/components/core/ScenePage";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper"; import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
import DataView from "~/components/core/DataView";
import EmptyState from "~/components/core/EmptyState";
const STYLES_SCENE_PAGE = css` const STYLES_SCENE_PAGE = css`
padding: 0px; padding: 0px;
@ -16,11 +18,13 @@ const STYLES_SCENE_PAGE = css`
} }
`; `;
const STYLES_FILTER_TITLE_WRAPPER = css` const STYLES_DATAVIEWER_WRAPPER = (theme) => css`
position: absolute; width: 100%;
top: 50%; min-height: calc(100vh - ${theme.sizes.filterNavbar}px);
left: 50%; padding: calc(20px + ${theme.sizes.filterNavbar}px) 24px 44px;
transform: translate(-50%, -50%); @media (max-width: ${theme.sizes.mobile}px) {
padding: calc(31px + ${theme.sizes.filterNavbar}px) 16px 44px;
}
`; `;
export default function SceneFilesFolder({ viewer, page, onAction, isMobile }) { export default function SceneFilesFolder({ viewer, page, onAction, isMobile }) {
@ -45,23 +49,24 @@ export default function SceneFilesFolder({ viewer, page, onAction, isMobile }) {
index={index} index={index}
onChange={(index) => setIndex(index)} onChange={(index) => setIndex(index)}
/> />
<Filter.Provider viewer={viewer}> <div css={STYLES_DATAVIEWER_WRAPPER}>
<Filter.NavbarPortal> {objects.length ? (
<div css={Styles.CONTAINER_CENTERED}> <DataView
<Filter.SidebarTrigger /> key="scene-files-folder"
<Filter.Breadcrumb style={{ marginLeft: 16 }} /> isOwner={true}
</div> items={objects}
<div css={STYLES_FILTER_TITLE_WRAPPER}> onAction={onAction}
<Filter.Title /> viewer={viewer}
</div> page={page}
<Filter.Actions /> view="grid"
</Filter.NavbarPortal> />
) : (
<div css={Styles.HORIZONTAL_CONTAINER}> <EmptyState>
<Filter.Sidebar /> <FileTypeGroup />
<Filter.Content onAction={onAction} viewer={viewer} page={page} /> <div style={{ marginTop: 24 }}>Drag and drop files into Slate to upload</div>
</div> </EmptyState>
</Filter.Provider> )}
</div>
</ScenePage> </ScenePage>
</WebsitePrototypeWrapper> </WebsitePrototypeWrapper>
); );

View File

@ -261,6 +261,26 @@ export default class SceneSlate extends React.Component {
} }
} }
const STYLES_RESET_SCENE_PAGE_PADDING = css`
padding: 0px;
@media (max-width: ${Constants.sizes.mobile}px) {
padding: 0px;
}
`;
const STYLES_DATAVIEWER_WRAPPER = (theme) => css`
width: 100%;
min-height: calc(100vh - ${theme.sizes.filterNavbar}px);
padding: calc(20px + ${theme.sizes.filterNavbar}px) 24px 44px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: calc(31px + ${theme.sizes.filterNavbar}px) 16px 44px;
}
`;
const STYLES_DATAVIEWER_WRAPPER_EXTERNAL = css`
margin-top: 40px;
`;
class SlatePage extends React.Component { class SlatePage extends React.Component {
_copy = null; _copy = null;
_timeout = null; _timeout = null;
@ -394,74 +414,34 @@ class SlatePage extends React.Component {
const { user, name, objects, body, isPublic, ownerId } = this.props.data; const { user, name, objects, body, isPublic, ownerId } = this.props.data;
const isOwner = this.props.viewer ? ownerId === this.props.viewer.id : false; const isOwner = this.props.viewer ? ownerId === this.props.viewer.id : false;
let actions = isOwner ? (
<span css={Styles.HORIZONTAL_CONTAINER}>
<SquareButtonGray onClick={this._handleDownload} style={{ marginRight: 16 }}>
<SVG.Download height="16px" />
</SquareButtonGray>
<Upload.Trigger viewer={this.props.viewer} style={{ marginRight: 16 }}>
<SquareButtonGray>
<SVG.Plus height="16px" />
</SquareButtonGray>
</Upload.Trigger>
<SquareButtonGray onClick={this._handleShowSettings}>
<SVG.Settings height="16px" />
</SquareButtonGray>
</span>
) : (
<div style={{ display: `flex` }}>
<SquareButtonGray onClick={this._handleDownload} style={{ marginRight: 16 }}>
<SVG.Download height="16px" />
</SquareButtonGray>
<div onClick={this._handleSubscribe}>
{this.state.isSubscribed ? (
<ButtonSecondary>Unsubscribe</ButtonSecondary>
) : (
<ButtonPrimary>Subscribe</ButtonPrimary>
)}
</div>
</div>
);
return ( return (
<ScenePage> <ScenePage css={!this.props.external && STYLES_RESET_SCENE_PAGE_PADDING}>
<ScenePageHeader {this.props.external ? (
wide <ScenePageHeader
title={ wide
user && !isOwner ? ( title={
<span> user && !isOwner ? (
<Link href={`/$/user/${user.id}`} onAction={this.props.onAction}> <span>
<span <Link href={`/$/user/${user.id}`} onAction={this.props.onAction}>
// onClick={() => <span css={STYLES_USERNAME}>{user.username}</span>{" "}
// this.props.onAction({ </Link>
// type: "NAVIGATE", / {name}
// value: "NAV_PROFILE", </span>
// shallow: true, ) : (
// data: user, <div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
// }) <span>{name}</span>
// } {isOwner && !isPublic && (
css={STYLES_USERNAME} <div css={STYLES_SECURITY_LOCK_WRAPPER} style={{ marginLeft: 16 }}>
> <SVG.SecurityLock height="16px" style={{ display: "block" }} />
{user.username} </div>
</span>{" "} )}
</Link> </div>
/ {name} )
</span> }
) : ( >
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}> {body}
<span>{name}</span> </ScenePageHeader>
{isOwner && !isPublic && ( ) : null}
<div css={STYLES_SECURITY_LOCK_WRAPPER} style={{ marginLeft: 16 }}>
<SVG.SecurityLock height="16px" style={{ display: "block" }} />
</div>
)}
</div>
)
}
actions={<span css={STYLES_MOBILE_HIDDEN}>{actions}</span>}
>
{body}
</ScenePageHeader>
<span css={STYLES_MOBILE_ONLY}>{actions}</span>
{objects && objects.length ? ( {objects && objects.length ? (
<> <>
<GlobalCarousel <GlobalCarousel
@ -476,7 +456,11 @@ class SlatePage extends React.Component {
index={this.state.index} index={this.state.index}
onChange={(index) => this.setState({ index })} onChange={(index) => this.setState({ index })}
/> />
<div style={{ marginTop: 40 }}> <div
css={
this.props.external ? STYLES_DATAVIEWER_WRAPPER_EXTERNAL : STYLES_DATAVIEWER_WRAPPER
}
>
<DataView <DataView
key="scene-files-folder" key="scene-files-folder"
type="collection" type="collection"

View File

@ -1,8 +0,0 @@
import { getFilterHandler } from "~/common/filter-utilities";
onmessage = function (e) {
const { objects = [], view, subview, type } = e.data;
const filterCallback = getFilterHandler({ view, subview, type });
const result = objects.filter(filterCallback);
postMessage(result);
};