added: new colors tokens

This commit is contained in:
Aminejvm 2021-05-27 09:20:34 +01:00 committed by Martina
parent c180d43e47
commit 3f863a3d0c
87 changed files with 4659 additions and 502 deletions

View File

@ -366,6 +366,13 @@ export const removeFileFromSlate = async (data) => {
});
};
export const like = async (data) => {
return await returnJSON(`/api/data/like`, {
...DEFAULT_OPTIONS,
body: JSON.stringify({ data }),
});
};
export const generateAPIKey = async () => {
await Websockets.checkWebsocket();
return await returnJSON(`/api/keys/generate`, {

View File

@ -0,0 +1,175 @@
/* eslint-disable no-prototype-builtins */
import shuffle from "lodash/shuffle";
import * as Actions from "~/common/actions";
// NOTE(amine): fetch explore objects
export const fetchExploreItems = async ({ currentItems, update }) => {
const requestObject = {};
if (currentItems.length) {
if (update) {
requestObject.latestTimestamp = currentItems[0].createdAt;
} else {
requestObject.earliestTimestamp = currentItems[currentItems.length - 1].createdAt;
}
}
const response = await Actions.getExplore(requestObject);
return response;
};
// NOTE(amine): fetch explore objects
export const fetchActivityItems = async ({ currentItems, viewer, update }) => {
const requestObject = {};
if (currentItems.length) {
if (update) {
requestObject.latestTimestamp = currentItems[0].createdAt;
} else {
requestObject.earliestTimestamp = currentItems[currentItems.length - 1].createdAt;
}
}
requestObject.following = viewer.following.map((item) => item.id);
requestObject.subscriptions = viewer.subscriptions.map((item) => item.id);
const response = await Actions.getActivity(requestObject);
return response;
};
//NOTE(martina): our grouping schema is as follows: we group first by multiple people doing the same action to the same target, then by one person doing the same action to different targets
// We remove repeat targets so that the user is not shown the same file/slate twice
let ids = {};
//NOTE(martina): this is used when grouping by multiple different users doing the same action on the same target. The value here is the target that should be the same (in addition to the type of action)
// e.g. "Martina, Tara, and Haris liked this file"
let fieldGroupings = {
SUBSCRIBE_SLATE: "slate",
SUBSCRIBE_USER: "user",
LIKE_FILE: "file",
SAVE_COPY: "file",
};
//NOTE(martina): this is used when grouping by one user doing the same action to different targets
// e.g. "Martina liked 3 files"
//NOTE(martina): primary is the primary "target" of the action (the thing that can differ). If there is a secondary, it is something that should stay consistent when grouping items with different values for the primary
// For example, for CREATE_SLATE_OBJECT, primary = file and secondary = slate.
// In other words, "Martina added 3 files to this one slate" is a valid grouping (slate is consistent), whereas "Martina added 3 files to 3 different slates" is not a valid grouping (slate is not consistent)
const ownerIdGroupings = {
CREATE_SLATE_OBJECT: { primary: "file", secondary: "slate" },
CREATE_SLATE: { primary: "slate" },
CREATE_FILE: { primary: "file", secondary: "slate" },
SUBSCRIBE_SLATE: { primary: "slate" },
SUBSCRIBE_USER: { primary: "user" },
FILE_VISIBLE: { primary: "file" },
SLATE_VISIBLE: { primary: "slate" },
LIKE_FILE: { primary: "file" },
SAVE_COPY: { primary: "file" },
};
//NOTE(martina): pass the new activity items through this to group and order them
export const processActivity = (activity) => {
let activityByType = {};
for (let item of activity) {
if (item.type === "DOWNLOAD_FILE") continue;
const { primary } = ownerIdGroupings[item.type];
const { id } = item[primary];
if (ids[id]) {
continue; //NOTE(martina): removing repeats from previous activity
}
if (activityByType[item.type]) {
activityByType[item.type].push(item);
} else {
activityByType[item.type] = [item];
}
}
//NOTE(martina): first grouping by multiple people doing the same action on the same target
let finalActivity = [];
for (let [type, events] of Object.entries(activityByType)) {
if (!fieldGroupings.hasOwnProperty(type)) continue;
let field = fieldGroupings[type];
const { grouped, ungrouped } = groupByField(events, field);
if (grouped?.length) {
finalActivity.push(...grouped);
}
if (ungrouped?.length) {
activityByType[type] = ungrouped;
} else {
delete activityByType[type];
}
}
//NOTE(martina): removing repeats within the group
for (let item of finalActivity) {
const { primary } = ownerIdGroupings[item.type];
let { id } = item[primary];
ids[id] = true;
}
for (let [key, arr] of Object.entries(activityByType)) {
let filteredArr = [];
for (let item of arr) {
const { primary } = ownerIdGroupings[item.type];
let { id } = item[primary];
if (ids[id]) {
continue;
} else {
filteredArr.push(item);
ids[id] = true;
}
}
activityByType[key] = filteredArr;
}
//NOTE(martina): second grouping of same owner doing same action on different targets
for (let [type, events] of Object.entries(activityByType)) {
if (!ownerIdGroupings.hasOwnProperty(type)) continue;
let field = ownerIdGroupings[type];
const groupedActivity = groupByOwner(events, field.primary, field.secondary);
finalActivity.push(...groupedActivity);
}
return shuffle(finalActivity);
};
const groupByField = (activity, key) => {
let ungrouped = {};
let grouped = {};
for (let item of activity) {
const { id } = item[key];
let match = ungrouped[id];
if (match) {
grouped[id] = match;
grouped[id].owner = [match.owner, item.owner];
delete ungrouped[id];
} else {
match = grouped[id];
if (match) {
grouped[id].owner.push(item.owner);
} else {
ungrouped[id] = item;
}
}
}
return { grouped: Object.values(grouped), ungrouped: Object.values(ungrouped) };
};
const groupByOwner = (activity, collateCol, sharedCol = "") => {
let grouped = {};
for (let item of activity) {
let aggregateKey = `${item.owner.id}-${sharedCol ? item[sharedCol]?.id : ""}`;
let match = grouped[aggregateKey];
if (match) {
if (Array.isArray(match[collateCol])) {
match[collateCol].push(item[collateCol]);
} else {
match[collateCol] = [match[collateCol], item[collateCol]];
}
} else {
grouped[aggregateKey] = item;
}
}
return Object.values(grouped);
};
//TODO(martina): add ranking by score, removing repeats from a persistent pool of ids before the first grouping as well (for that one, don't add to the ids list yet)

View File

@ -1,5 +1,7 @@
import * as React from "react";
import * as Logging from "~/common/logging";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
export const useMounted = () => {
const isMounted = React.useRef(true);
@ -39,10 +41,12 @@ export const useForm = ({
});
const _hasError = (obj) => Object.keys(obj).some((name) => obj[name]);
const _mergeEventHandlers = (events = []) => (e) =>
events.forEach((event) => {
if (event) event(e);
});
const _mergeEventHandlers =
(events = []) =>
(e) =>
events.forEach((event) => {
if (event) event(e);
});
/** ---------- NOTE(amine): Input Handlers ---------- */
const handleFieldChange = (e) =>
@ -162,10 +166,12 @@ export const useField = ({
touched: undefined,
});
const _mergeEventHandlers = (events = []) => (e) =>
events.forEach((event) => {
if (event) event(e);
});
const _mergeEventHandlers =
(events = []) =>
(e) =>
events.forEach((event) => {
if (event) event(e);
});
/** ---------- NOTE(amine): Input Handlers ---------- */
const handleFieldChange = (e) =>
@ -176,14 +182,14 @@ export const useField = ({
touched: false,
}));
const handleOnBlur = (e) => {
const handleOnBlur = () => {
// NOTE(amine): validate the inputs onBlur and touch the current input
let error = {};
if (validateOnBlur && validate) error = validate(state.value);
setState((prev) => ({ ...prev, touched: validateOnBlur, error }));
};
const handleFormOnSubmit = (e) => {
const handleFormOnSubmit = () => {
//NOTE(amine): touch all inputs
setState((prev) => ({ ...prev, touched: true }));
@ -219,3 +225,169 @@ export const useField = ({
return { getFieldProps, value: state.value, isSubmitting: state.isSubmitting };
};
export const useIntersection = ({ onIntersect, ref }, dependencies = []) => {
// NOTE(amine): fix for stale closure caused by hooks
const onIntersectRef = React.useRef();
onIntersectRef.current = onIntersect;
React.useLayoutEffect(() => {
if (!ref.current) return;
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (onIntersectRef.current) onIntersectRef.current(lazyObserver, ref);
}
});
});
// start to observe element
lazyObserver.observe(ref.current);
return () => lazyObserver.unobserve(ref.current);
}, dependencies);
};
// NOTE(amine): the intersection will be called one time
export const useInView = ({ ref }) => {
const [isInView, setInView] = React.useState(false);
useIntersection({
ref,
onIntersect: (lazyObserver, ref) => {
setInView(true);
lazyObserver.unobserve(ref.current);
},
});
return { isInView };
};
// NOTE(amine): manage like state
export const useLikeHandler = ({ file, viewer }) => {
const likedFile = React.useMemo(() => viewer?.likes?.find((item) => item.id === file.id), []);
const [state, setState] = React.useState({
isLiked: !!likedFile,
// NOTE(amine): viewer will have the hydrated state
likeCount: likedFile?.likeCount ?? file.likeCount,
});
const handleLikeState = () => {
setState((prev) => {
if (prev.isLiked) {
return {
isLiked: false,
likeCount: prev.likeCount - 1,
};
}
return {
isLiked: true,
likeCount: prev.likeCount + 1,
};
});
};
const like = async () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
// NOTE(amine): optimistic update
handleLikeState();
const response = await Actions.like({ id: file.id });
if (Events.hasError(response)) {
// NOTE(amine): revert back to old state if there is an error
handleLikeState();
return;
}
};
return { like, ...state };
};
// NOTE(amine): manage file saving state
export const useSaveHandler = ({ file, viewer }) => {
const savedFile = React.useMemo(() => viewer?.libraryCids[file.cid], [viewer]);
const [state, setState] = React.useState({
isSaved: !!savedFile,
// NOTE(amine): viewer will have the hydrated state
saveCount: file.saveCount,
});
const handleSaveState = () => {
setState((prev) => {
if (prev.isSaved) {
return {
isSaved: false,
saveCount: prev.saveCount - 1,
};
}
return {
isSaved: true,
saveCount: prev.saveCount + 1,
};
});
};
const save = async () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
// NOTE(amine): optimistic update
handleSaveState();
const response =
state.isSaved && savedFile
? await Actions.deleteFiles({ ids: [savedFile.id] })
: await Actions.saveCopy({ files: [file] });
if (Events.hasError(response)) {
// NOTE(amine): revert back to old state if there is an error
handleSaveState();
return;
}
};
return { save, ...state };
};
export const useFollowProfileHandler = ({ user, viewer, onAction }) => {
const [isFollowing, setFollowing] = React.useState(
!viewer
? false
: !!viewer?.following.some((entry) => {
return entry.id === user.id;
})
);
const handleFollow = async (userId) => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
setFollowing((prev) => !prev);
const response = await Actions.createSubscription({
userId,
});
if (Events.hasError(response)) {
setFollowing((prev) => !prev);
return;
}
onAction({
type: "UPDATE_VIEWER",
viewer: {
following: isFollowing
? viewer.following.filter((user) => user.id !== userId)
: viewer.following.concat([
{
id: user.id,
data: user.data,
fileCount: user.fileCount,
followerCount: user.followerCount + 1,
slateCount: user.slateCount,
username: user.username,
},
]),
},
});
};
return { handleFollow, isFollowing };
};

View File

@ -47,7 +47,6 @@ export const H2 = css`
export const H3 = css`
font-family: ${Constants.font.text};
font-size: 1.25rem;
font-weight: medium;
line-height: 1.5;
letter-spacing: -0.017px;
@ -57,7 +56,6 @@ export const H3 = css`
export const H4 = css`
font-family: ${Constants.font.text};
font-size: 1rem;
font-weight: medium;
line-height: 1.5;
letter-spacing: -0.011px;
@ -67,7 +65,6 @@ export const H4 = css`
export const H5 = css`
font-family: ${Constants.font.text};
font-size: 0.875rem;
font-weight: medium;
line-height: 1.5;
letter-spacing: -0.006px;
@ -81,6 +78,14 @@ export const P1 = css`
line-height: 1.5;
letter-spacing: -0.011px;
@media (max-width: ${Constants.sizes.mobile}px) {
font-family: ${Constants.font.text};
font-size: 0.875rem;
font-weight: regular;
line-height: 1.5;
letter-spacing: -0.006px;
}
${TEXT}
`;
@ -91,6 +96,14 @@ export const P2 = css`
line-height: 1.5;
letter-spacing: -0.006px;
@media (max-width: ${Constants.sizes.mobile}px) {
font-family: ${Constants.font.text};
font-size: 0.75rem;
font-weight: normal;
line-height: 1.3;
letter-spacing: 0px;
}
${TEXT}
`;
@ -206,3 +219,47 @@ export const MOBILE_ONLY = css`
pointer-events: none;
}
`;
/* COMMON GRIDS */
export const OBJECTS_PREVIEW_GRID = css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
grid-gap: 24px 16px;
@media (max-width: ${Constants.sizes.mobile}px) {
grid-gap: 20px 8px;
grid-template-columns: repeat(auto-fill, minmax(166px, 1fr));
}
`;
export const BUTTON_RESET = css`
padding: 0;
margin: 0;
background-color: unset;
border: none;
${HOVERABLE}
`;
export const COLLECTIONS_PREVIEW_GRID = css`
display: grid;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(432px, 1fr));
grid-gap: 24px 16px;
@media (max-width: ${Constants.sizes.desktop}px) {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-gap: 20px 8px;
}
`;
export const PROFILE_PREVIEW_GRID = css`
display: grid;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(432px, 1fr));
grid-gap: 24px 16px;
@media (max-width: ${Constants.sizes.mobile}px) {
grid-gap: 20px 8px;
grid-template-columns: repeat(auto-fill, minmax(344px, 1fr));
}
`;

View File

@ -75,7 +75,7 @@ export const injectGlobalStyles = () => css`
}
html, body {
background: ${Constants.semantic.bgLight};
background: ${Constants.system.white};
color: ${Constants.system.black};
font-size: 16px;
font-family: ${Constants.font.text};

View File

@ -1890,6 +1890,18 @@ export const MehCircle = (props) => (
</svg>
);
export const Heart = (props) => (
<svg width={20} height={21} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M17.367 4.342a4.584 4.584 0 00-6.484 0L10 5.225l-.883-.883a4.584 4.584 0 00-6.484 6.483l.884.883L10 18.192l6.483-6.484.884-.883a4.584 4.584 0 000-6.483v0z"
stroke="#48484A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export const SmileCircle = (props) => (
<svg width={16} height={17} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
@ -1908,3 +1920,52 @@ export const SmileCircle = (props) => (
/>
</svg>
);
export const FolderPlus = (props) => (
<svg width={20} height={21} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M18.333 16.333A1.667 1.667 0 0116.667 18H3.333a1.667 1.667 0 01-1.666-1.667V4.667A1.667 1.667 0 013.333 3H7.5l1.667 2.5h7.5a1.667 1.667 0 011.666 1.667v9.166zM10 9.667v5M7.5 12.167h5"
stroke="#48484A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export const PlayButton = (props) => (
<svg width={40} height={40} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g filter="url(#prefix__filter0_b)">
<rect width={40} height={40} rx={20} fill="#fff" fillOpacity={0.3} />
<path d="M15.333 14l9.334 6-9.334 6V14z" fill="#F2F2F7" />
</g>
<defs>
<filter
id="prefix__filter0_b"
x={-75}
y={-75}
width={190}
height={190}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImage" stdDeviation={37.5} />
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur" />
<feBlend in="SourceGraphic" in2="effect1_backgroundBlur" result="shape" />
</filter>
</defs>
</svg>
);
export const RSS = (props) => (
<svg width={20} height={21} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3.333 9.667a7.5 7.5 0 017.5 7.5M3.333 3.833a13.333 13.333 0 0113.334 13.334M4.167 17.167a.833.833 0 100-1.667.833.833 0 000 1.667z"
stroke="#000002"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -83,3 +83,42 @@ export const coerceToArray = (input) => {
return [input];
}
};
export const getFileExtension = (filename) => filename?.split(".").pop();
export const getTimeDifferenceFromNow = (date) => {
const pastDate = new Date(date);
const now = new Date();
const differenceInSeconds = Math.floor((now - pastDate) / 1000);
if (differenceInSeconds < 60) {
return differenceInSeconds + "s";
}
const differenceInMinutes = Math.floor(differenceInSeconds / 60);
if (differenceInMinutes < 60) {
return differenceInMinutes + "m";
}
const differenceInHours = Math.floor(differenceInMinutes / 60);
if (differenceInHours < 24) {
return differenceInHours + "h";
}
const differenceInDays = Math.floor(differenceInHours / 24);
if (differenceInDays < 24) {
return differenceInDays + "d";
}
const currentYear = now.getFullYear();
const day = pastDate.getDay();
const month = pastDate.toLocaleString("default", { month: "long" });
const year = pastDate.getFullYear();
if (year === currentYear) {
return `${day} ${month}`;
}
return `${day} ${month} ${year}`;
};

View File

@ -245,6 +245,17 @@ export const isUnityType = (type = "") => {
return type === "application/unity";
};
export const is3dFile = (filename = "") => {
return endsWithAny(
[".stl", ".obj", ".fbx", ".blend", ".c4d", ".glb", ".dae", ".3ds", ".wrl"],
filename.toLowerCase()
);
};
export const isCodeFile = (filename = "") => {
return endsWithAny([".js"], filename.toLowerCase());
};
export const isFontFile = (fileName = "") => {
return endsWithAny([".ttf", ".otf", ".woff", ".woff2"], fileName.toLowerCase());
};

View File

@ -0,0 +1,100 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { Link } from "~/components/core/Link";
import { motion } from "framer-motion";
import { ViewMoreContent, ProfileInfo } from "~/components/core/ActivityGroup/components";
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
const STYLES_GROUP_GRID = (theme) => css`
display: grid;
grid-template-columns: 260px 1fr;
grid-row-gap: 32px;
border-bottom: 1px solid ${theme.semantic.bgLight};
padding-bottom: 24px;
@media (max-width: ${theme.sizes.mobile}px) {
grid-row-gap: 24px;
grid-template-columns: 1fr;
}
`;
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
@media (max-width: ${theme.sizes.mobile}px) {
display: flex;
justify-content: center;
}
`;
export default function ActivityCollectionGroup({ onAction, viewer, group, ...props }) {
const { owner, slate, type, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(slate)) {
return { elements: [slate] };
}
return { elements: slate.slice(0, 2), restElements: slate.slice(2) };
}, [slate]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(createdAt);
// const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(elements[0].createdAt);
const nbrOfFilesUploaded = elements.length + (restElements?.length || 0);
const action = React.useMemo(() => {
if (type === "SUBSCRIBE_SLATE") {
return "started following";
}
return `created ${nbrOfFilesUploaded} ${Strings.pluralize("collection", nbrOfFilesUploaded)}`;
}, []);
return (
<div css={STYLES_GROUP_GRID} {...props}>
<ProfileInfo
time={timeSinceUploaded}
owner={owner}
viewer={viewer}
action={action}
onAction={onAction}
/>
<div>
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
{elements.map((collection) => (
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
<CollectionPreviewBlock collection={collection} viewer={viewer} />
</Link>
))}
{showMore &&
restElements.map((collection, i) =>
// NOTE(amine): animate only the first 8 elements
i < 8 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
key={collection.id}
>
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
<CollectionPreviewBlock collection={collection} viewer={viewer} />
</Link>
</motion.div>
) : (
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
<CollectionPreviewBlock collection={collection} viewer={viewer} />
</Link>
)
)}
</div>
<div css={STYLES_VIEWMORE_CONTAINER}>
{!showMore && restElements?.length ? (
<ViewMoreContent onClick={viewMoreFiles}>
View {restElements.length} more {Strings.pluralize("collection", restElements.length)}
</ViewMoreContent>
) : null}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import * as React from "react";
import * as Strings from "~/common/strings";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { motion } from "framer-motion";
import { ViewMoreContent, ProfileInfo } from "~/components/core/ActivityGroup/components";
import ObjectPreview from "~/components/core/ObjectPreview";
const STYLES_OBJECT_GRID = (theme) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
grid-gap: 20px 12px;
@media (max-width: ${theme.sizes.mobile}px) {
grid-row-gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(169px, 1fr));
}
`;
const STYLES_GROUP_GRID = (theme) => css`
display: grid;
grid-template-columns: 260px 1fr;
grid-row-gap: 32px;
border-bottom: 1px solid ${theme.semantic.bgLight};
padding-bottom: 24px;
@media (max-width: ${theme.sizes.mobile}px) {
grid-template-columns: 1fr;
}
`;
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
@media (max-width: ${theme.sizes.mobile}px) {
display: flex;
justify-content: center;
}
`;
export default function ActivityFileGroup({ viewer, group, onAction }) {
const { file, owner, slate, type, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(file)) {
return { elements: [file] };
}
return { elements: file.slice(0, 4), restElements: file.slice(4) };
}, [file]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
const timeSinceAction = Utilities.getTimeDifferenceFromNow(createdAt);
const nbrOfFiles = elements.length + (restElements?.length || 0);
const action = React.useMemo(() => {
if (type === "CREATE_FILE")
return `uploaded ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)} ${
slate ? `to ${slate.slatename}` : ""
}`;
if (type === "LIKE_FILE") return `liked ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)}`;
if (type === "SAVE_COPY") return `saved ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)}`;
return `added ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)} ${
slate && `to ${slate.slatename}`
}`;
}, []);
return (
<div css={STYLES_GROUP_GRID}>
<ProfileInfo
time={timeSinceAction}
owner={owner}
action={action}
viewer={viewer}
onAction={onAction}
/>
<div>
<div css={STYLES_OBJECT_GRID}>
{elements.map((file) => (
<ObjectPreview viewer={viewer} owner={file.owner} key={file.id} file={file} />
))}
{showMore &&
restElements.map((file, i) =>
// NOTE(amine): animate only the first 8 elements
i < 8 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
key={file.id}
>
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
</motion.div>
) : (
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
)
)}
</div>
<div css={STYLES_VIEWMORE_CONTAINER}>
{!showMore && restElements?.length ? (
<ViewMoreContent items={restElements} onClick={viewMoreFiles}>
View {restElements.length} more {Strings.pluralize("file", restElements.length)}
</ViewMoreContent>
) : null}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import { ProfileInfo } from "~/components/core/ActivityGroup/components";
import { Link } from "~/components/core/Link";
import { motion } from "framer-motion";
import { ViewMoreContent } from "~/components/core/ActivityGroup/components";
import ProfilePreview from "~/components/core/ProfilePreviewBlock";
const STYLES_GROUP_GRID = (theme) => css`
display: grid;
grid-template-columns: 260px 1fr;
grid-row-gap: 32px;
border-bottom: 1px solid ${theme.semantic.bgLight};
padding-bottom: 24px;
@media (max-width: ${theme.sizes.mobile}px) {
grid-row-gap: 24px;
grid-template-columns: 1fr;
}
`;
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
@media (max-width: ${theme.sizes.mobile}px) {
display: flex;
justify-content: center;
}
`;
export default function ActivityProfileGroup({ viewer, external, group, onAction }) {
const { owner, user, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(user)) {
return { elements: [user] };
}
return { elements: user.slice(0, 3), restElements: user.slice(3) };
}, [user]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(createdAt);
return (
<div css={STYLES_GROUP_GRID}>
<ProfileInfo
time={timeSinceUploaded}
owner={owner}
action={"started following"}
viewer={viewer}
onAction={onAction}
/>
<div>
<div css={Styles.PROFILE_PREVIEW_GRID}>
{elements.map((user) => (
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
<ProfilePreview
onAction={onAction}
viewer={viewer}
external={external}
profile={user}
/>
</Link>
))}
{showMore &&
restElements.map((user, i) =>
// NOTE(amine): animate only the first 8 elements
i < 8 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
key={user.id}
>
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
<ProfilePreview
onAction={onAction}
viewer={viewer}
external={external}
profile={user}
/>
</Link>
</motion.div>
) : (
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
<ProfilePreview onAction={onAction} profile={user} />
</Link>
)
)}
</div>
<div css={STYLES_VIEWMORE_CONTAINER}>
{!showMore && restElements?.length ? (
<ViewMoreContent onClick={viewMoreFiles}>
View {restElements.length} more {Strings.pluralize("profile", restElements.length)}
</ViewMoreContent>
) : null}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import { Link } from "~/components/core/Link";
import { css } from "@emotion/react";
import { P2, H4 } from "~/components/system/components/Typography";
import { ButtonPrimary, ButtonTertiary } from "~/components/system";
import { useFollowProfileHandler } from "~/common/hooks";
const STYLES_PROFILE_CONTAINER = css`
display: flex;
padding-right: 12px;
box-sizing: border-box;
& > * + * {
margin-left: 12px;
}
`;
const STYLES_TEXT_BLACK = (theme) =>
css`
color: ${theme.semantic.textBlack};
display: inline;
`;
const STYLES_PROFILE = css`
width: 48px;
height: 48px;
border-radius: 8px;
`;
const STYLES_MOBILE_ALIGN = (theme) => css`
@media (max-width: ${theme.sizes.mobile}px) {
width: 100%;
& > span {
display: flex;
justify-content: space-between;
align-items: center;
}
}
`;
export default function ProfileInfo({ owner, viewer, time, action, onAction }) {
const { isFollowing, handleFollow } = useFollowProfileHandler({ viewer, user: owner, onAction });
const { username, data = {} } = owner;
const { photo } = data;
const isOwner = viewer?.id === owner.id;
return (
<Link href={`/$/user/${owner.id}`} onAction={onAction}>
<div css={STYLES_PROFILE_CONTAINER}>
<img src={photo} alt={`${username} profile`} css={STYLES_PROFILE} />
<div css={STYLES_MOBILE_ALIGN}>
<span>
<H4 color="textBlack" css={[STYLES_TEXT_BLACK, Styles.HEADING_04]}>
{username}
</H4>
<H4
color="textBlack"
css={[STYLES_TEXT_BLACK, Styles.HEADING_04, Styles.MOBILE_HIDDEN]}
>
&nbsp;&nbsp;
</H4>
<P2 color="textGrayDark" style={{ display: "inline" }}>
{time}
</P2>
</span>
<P2 color="textGrayDark" nbrOflines={2}>
{action}
</P2>
{!isOwner && (
<div style={{ marginTop: 12 }} css={Styles.MOBILE_HIDDEN}>
{isFollowing ? (
<ButtonTertiary
style={{ marginTop: "auto", maxWidth: "91px" }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(owner.id);
}}
>
Following
</ButtonTertiary>
) : (
<ButtonPrimary
style={{ marginTop: "auto", maxWidth: "91px" }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(owner.id);
}}
>
Follow
</ButtonPrimary>
)}
</div>
)}
</div>
</div>
</Link>
);
}

View File

@ -0,0 +1,68 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import { css } from "@emotion/react";
import { H5 } from "~/components/system/components/Typography";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_VIEW_MORE_CONTAINER = (theme) => css`
background-color: ${theme.semantic.bgLight};
border: none;
padding: 8px;
border-radius: 8px;
margin-top: 24px;
`;
const STYLES_SHOW_MORE_PREVIEWS = (theme) => css`
overflow: hidden;
border-radius: 4px;
height: 24px;
width: 24px;
background-color: ${theme.system.grayLight5};
& > img {
width: 100%;
height: 100%;
}
`;
const getImageCover = (item) => {
const coverImage = item?.data?.coverImage;
const imageUrl = Strings.getURLfromCID(coverImage ? coverImage?.cid : item.cid);
return imageUrl;
};
export default function ViewMoreContent({ items, children, ...props }) {
return (
<button css={[Styles.HOVERABLE, STYLES_VIEW_MORE_CONTAINER]} {...props}>
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
{items && (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
{items?.slice(0, 3).map((file) => {
const isImageFile =
Validations.isPreviewableImage(file?.data?.type) || file?.data?.coverImage;
return (
<div
key={file.id}
style={{ marginLeft: 2 }}
css={[STYLES_SHOW_MORE_PREVIEWS, Styles.CONTAINER_CENTERED]}
>
{isImageFile ? (
<img src={getImageCover(file)} alt="File Preview" />
) : (
<ObjectPlaceholder ratio={0.4} file={file} />
)}
</div>
);
})}
</div>
)}
<H5 style={{ marginLeft: items ? 12 : 0 }} color="textGrayDark">
{children}
</H5>
</div>
</button>
);
}

View File

@ -0,0 +1,5 @@
export { default as ViewMoreContent } from "./ViewMoreContent";
export { default as ProfileInfo } from "./ProfileInfo";
export { default as ActivityCollectionGroup } from "./ActivityCollectionGroup";
export { default as ActivityFileGroup } from "./ActivityFileGroup";
export { default as ActivityProfileGroup } from "./ActivityProfileGroup";

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { css } from "@emotion/react";
import {
ActivityFileGroup,
ActivityCollectionGroup,
ActivityProfileGroup,
} from "~/components/core/ActivityGroup/components";
const STYLES_GROUP_GRID = (theme) => css`
display: grid;
grid-template-columns: 260px 1fr;
grid-row-gap: 32px;
border-bottom: 1px solid ${theme.semantic.bgLight};
padding-bottom: 24px;
`;
export default function ActivityGroup({ onAction, viewer, external, group }) {
const { type } = group;
if (
type === "CREATE_FILE" ||
type === "CREATE_SLATE_OBJECT" ||
type === "LIKE_FILE" ||
type === "SAVE_COPY"
) {
return <ActivityFileGroup viewer={viewer} onAction={onAction} group={group} />;
}
if (type === "CREATE_SLATE" || type === "SUBSCRIBE_SLATE") {
return <ActivityCollectionGroup onAction={onAction} viewer={viewer} group={group} />;
}
if (type === "SUBSCRIBE_USER") {
return (
<ActivityProfileGroup onAction={onAction} viewer={viewer} external={external} group={group} />
);
}
// TODO(amine): grouping for making files/slate public
return (
<div css={STYLES_GROUP_GRID}>
<div>{type}</div>
</div>
);
}

View File

@ -47,9 +47,10 @@ const STYLES_NAV_LINK = css`
}
`;
const STYLES_APPLICATION_HEADER_CONTAINER = css`
const STYLES_APPLICATION_HEADER_CONTAINER = (theme) => css`
width: 100%;
background-color: ${Constants.system.white};
background-color: ${theme.system.white};
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
@supports ((-webkit-backdrop-filter: blur(25px)) or (backdrop-filter: blur(25px))) {
-webkit-backdrop-filter: blur(25px);

View File

@ -40,7 +40,7 @@ const STYLES_HEADER = css`
`;
const STYLES_CONTENT = css`
background: ${Constants.semantic.bgLight};
background: ${Constants.system.white};
width: 100%;
min-width: 10%;
min-height: 100vh;
@ -95,7 +95,7 @@ const STYLES_MODAL = css`
left: 0;
padding: ${MODAL_MARGIN}px;
background-color: ${Constants.system.bgBlurLight};
background-color: ${Constants.semantic.bgBlurLight6};
@supports ((-webkit-backdrop-filter: blur(25px)) or (backdrop-filter: blur(25px))) {
-webkit-backdrop-filter: blur(25px);

View File

@ -15,7 +15,7 @@ import { SignUpPopover, Verification, AuthCheckBox } from "~/components/core/Aut
const STYLES_SMALL = (theme) => css`
font-size: ${theme.typescale.lvlN1};
text-align: center;
color: ${theme.system.textGrayDark};
color: ${theme.semantic.textGrayDark};
max-width: 228px;
margin: 0 auto;
`;

View File

@ -22,7 +22,7 @@ const STYLES_POPOVER = (theme) => css`
background-color: white;
@supports ((-webkit-backdrop-filter: blur(25px)) or (backdrop-filter: blur(25px))) {
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background: radial-gradient(
80.79% 80.79% at 50% 50%,
rgba(242, 242, 247, 0.5) 0%,

View File

@ -0,0 +1,259 @@
import * as React from "react";
import * as Strings from "~/common/strings";
import * as Typography from "~/components/system/components/Typography";
import * as Styles from "~/common/styles";
import { Divider } from "~/components/system/components/Divider";
import { Logo } from "~/common/logo";
import { css } from "@emotion/react";
import { LikeButton, SaveButton } from "~/components/core/ObjectPreview/components";
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
import { FollowButton } from "~/components/core/CollectionPreviewBlock/components";
import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_CONTAINER = (theme) => css`
display: flex;
flex-direction: column;
position: relative;
border-radius: 8px;
width: 100%;
border-radius: 8px;
overflow: hidden;
background-color: ${theme.system.white};
height: 311px;
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
@media (max-width: ${theme.sizes.mobile}px) {
height: 281px;
}
`;
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
display: flex;
flex-direction: column;
position: relative;
padding: 12px 16px;
border-radius: 0px 0px 8px 8px;
background-color: ${theme.system.white};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background-color: ${theme.semantic.bgGrayLight};
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
width: 100%;
margin-top: auto;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 12px;
}
`;
const STYLES_SPACE_BETWEEN = css`
justify-content: space-between;
`;
const STYLES_CONTROLLS = css`
display: flex;
flex-direction: column;
align-items: flex-end;
`;
const STYLES_TEXT_BLACK = (theme) => css`
color: ${theme.semantic.textBlack};
`;
const STYLES_PROFILE_IMAGE = (theme) => css`
background-color: ${theme.semantic.bgLight};
height: 20px;
width: 20px;
border-radius: 4px;
object-fit: cover;
`;
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
box-sizing: border-box;
display: block;
padding: 4px 16px;
border: none;
background-color: unset;
div {
width: 8px;
height: 8px;
background-color: ${theme.system.gray};
border-radius: 50%;
}
`;
const STYLES_METRICS = (theme) => css`
margin-top: 16px;
@media (max-width: ${theme.sizes.mobile}px) {
margin-top: 12px;
}
${Styles.CONTAINER_CENTERED};
${STYLES_SPACE_BETWEEN};
`;
const STYLES_FILES_PREVIEWS = (theme) => css`
flex-grow: 1;
padding: 16px;
padding-right: 0px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 12px;
padding-right: 0px;
}
`;
const STYLES_PLACEHOLDER = css`
height: 64px;
min-width: 86px;
width: 86px;
`;
const CollectionPreviewFile = ({ file, viewer }) => {
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
const title = file.data.name || file.filename;
const { body } = file.data;
return (
<div css={[Styles.HORIZONTAL_CONTAINER]}>
<ObjectPlaceholder ratio={1.1} file={file} containerCss={STYLES_PLACEHOLDER} showTag />
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
<Typography.H5 color="textBlack" nbrOflines={1}>
{title}
</Typography.H5>
<Typography.P3 nbrOflines={1} color="textGrayDark">
{body}
</Typography.P3>
<div style={{ marginTop: "auto" }} css={Styles.HORIZONTAL_CONTAINER}>
<div css={Styles.CONTAINER_CENTERED}>
<LikeButton isLiked={isLiked} onClick={like} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{likeCount}
</Typography.P1>
</div>
<div style={{ marginLeft: 48 }} css={Styles.CONTAINER_CENTERED}>
<SaveButton onSave={save} isSaved={isSaved} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{saveCount}
</Typography.P1>
</div>
</div>
</div>
</div>
);
};
const useCollectionCarrousel = ({ objects }) => {
const [selectedIdx, setSelectedIdx] = React.useState(0);
const selectBatchIdx = (idx) => setSelectedIdx(idx);
const selectedBatch = objects[selectedIdx];
return { selectBatchIdx, selectedBatch, selectedIdx };
};
export default function CollectionPreview({ collection, viewer }) {
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
const filePreviews = React.useMemo(() => {
const files = collection?.objects || [];
let previews = [];
for (let i = 0; i < files.length; i++) {
const batch = [];
if (files[i * 2]) batch.push(files[i * 2]);
if (files[i * 2 + 1]) batch.push(files[i * 2 + 1]);
if (batch.length > 0) previews.push(batch);
if (previews.length === 3 || batch.length < 2) break;
}
return previews;
}, [collection]);
const { selectBatchIdx, selectedBatch, selectedIdx } = useCollectionCarrousel({
objects: filePreviews,
});
const nbrOfFiles = collection?.objects?.length || 0;
const isCollectionEmpty = nbrOfFiles === 0;
const showFollowButton = collection.ownerId !== viewer?.id;
return (
<div css={STYLES_CONTAINER}>
<div css={STYLES_FILES_PREVIEWS} style={{ display: "flex" }}>
<div style={{ width: "100%" }}>
{!isCollectionEmpty ? (
selectedBatch.map((file, i) => (
<React.Fragment key={file.id}>
{i === 1 && <Divider color="bgLight" style={{ margin: "8px 0px" }} />}
<CollectionPreviewFile file={file} viewer={viewer} />
</React.Fragment>
))
) : (
<div
style={{ height: "100%" }}
css={[Styles.CONTAINER_CENTERED, Styles.VERTICAL_CONTAINER]}
>
<Logo style={{ height: 18, marginBottom: 8 }} />
<Typography.P1 color="textGrayDark">No files in this collection</Typography.P1>
</div>
)}
</div>
{
<div css={STYLES_CONTROLLS}>
{filePreviews.map((preview, i) => (
<button
key={i}
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
selectBatchIdx(i);
}}
aria-label="Next Preview Image"
>
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
</button>
))}
</div>
}
</div>
<div css={STYLES_DESCRIPTION_CONTAINER}>
<div>
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
<Typography.H4 css={[Styles.HEADING_04, STYLES_TEXT_BLACK]}>
{collection.slatename}
</Typography.H4>
<Typography.P2 color="textGrayDark">
{nbrOfFiles} {Strings.pluralize("Object", nbrOfFiles)}
</Typography.P2>
</div>
{collection?.data?.body && (
<Typography.P2 style={{ marginTop: 4 }} nbrOflines={2} color="textGrayDark">
{collection?.data?.body}
</Typography.P2>
)}
</div>
<div css={[STYLES_METRICS]}>
<div css={Styles.CONTAINER_CENTERED}>
<FollowButton isFollowed={isFollowed} onFollow={showFollowButton && follow} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{followCount}
</Typography.P1>
</div>
<div css={Styles.CONTAINER_CENTERED}>
<img
css={STYLES_PROFILE_IMAGE}
src="https://slate.textile.io/ipfs/bafkreick3nscgixwfpq736forz7kzxvvhuej6kszevpsgmcubyhsx2pf7i"
alt="owner profile"
/>
<Typography.P2 style={{ marginLeft: 8 }} color="textGrayDark">
Wes Anderson
</Typography.P2>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
import * as React from "react";
import * as Strings from "~/common/strings";
import * as Typography from "~/components/system/components/Typography";
import * as Styles from "~/common/styles";
import { useInView } from "~/common/hooks";
import { isBlurhashValid } from "blurhash";
import { Blurhash } from "react-blurhash";
import { css } from "@emotion/react";
import { FollowButton } from "~/components/core/CollectionPreviewBlock/components";
import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks";
const STYLES_CONTAINER = (theme) => css`
display: flex;
flex-direction: column;
position: relative;
border-radius: 8px;
width: 100%;
border-radius: 8px;
overflow: hidden;
height: 311px;
@media (max-width: ${theme.sizes.mobile}px) {
height: 281px;
}
`;
const STYLES_PREVIEW = (theme) => css`
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background-color: ${theme.system.white};
background-size: cover;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
display: flex;
flex-direction: column;
position: relative;
padding: 12px 16px;
border-radius: 8px;
background-color: ${theme.system.white};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background-color: ${theme.semantic.bgBlurLight6OP};
-webkit-backdrop-filter: blur(75px);
backdrop-filter: blur(75px);
}
width: 100%;
margin-top: auto;
`;
const STYLES_SPACE_BETWEEN = css`
justify-content: space-between;
`;
const STYLES_CONTROLLS = css`
display: flex;
flex-direction: column;
align-items: flex-end;
position: relative;
padding: 16px 0px 16px 16px;
`;
const STYLES_PROFILE_IMAGE = (theme) => css`
background-color: ${theme.semantic.bgLight};
height: 20px;
width: 20px;
border-radius: 4px;
object-fit: cover;
`;
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
box-sizing: border-box;
display: block;
padding: 4px 16px;
border: none;
background-color: unset;
div {
width: 8px;
height: 8px;
background-color: ${theme.system.gray};
border-radius: 50%;
}
`;
const STYLES_METRICS = (theme) => css`
margin-top: 16px;
@media (max-width: ${theme.sizes.mobile}px) {
margin-top: 12px;
}
${Styles.CONTAINER_CENTERED};
${STYLES_SPACE_BETWEEN}
`;
const getFileBlurHash = (file) => {
const coverImage = file?.data?.coverImage;
const coverImageBlurHash = coverImage?.data?.blurhash;
if (coverImage && isBlurhashValid(coverImageBlurHash)) return coverImageBlurHash;
const blurhash = file?.data?.blurhash;
if (isBlurhashValid(blurhash)) return blurhash;
return null;
};
const useCollectionCarrousel = ({ objects }) => {
const [selectedIdx, setSelectedIdx] = React.useState(0);
const selectImageByIdx = (idx) => setSelectedIdx(idx);
const [imagesLoadedIdx, setImagesLoadedIdx] = React.useState({});
const handleLoading = () => setImagesLoadedIdx((prev) => ({ ...prev, [selectedIdx]: true }));
const isCurrentImageLoaded = imagesLoadedIdx[selectedIdx];
const { image: selectedImage, blurhash } = objects[selectedIdx];
return {
selectedImage,
selectedIdx,
isLoaded: isCurrentImageLoaded,
blurhash,
handleLoading,
selectImageByIdx,
};
};
export default function ImageCollectionPreview({ collection, viewer }) {
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
const filePreviews = React.useMemo(() => {
const previews = collection.objects.map((object) => {
const coverImage = object?.data?.coverImage;
const image = coverImage || Strings.getURLfromCID(object.cid);
const blurhash = getFileBlurHash(object);
return { image, blurhash };
});
return previews.slice(0, 3);
}, [collection]);
const previewerRef = React.useRef();
const { isInView } = useInView({
ref: previewerRef,
});
const {
isLoaded,
blurhash,
selectedImage,
handleLoading,
selectedIdx,
selectImageByIdx,
} = useCollectionCarrousel({ objects: filePreviews });
const nbrOfFiles = collection?.objects?.length || 0;
const showFollowButton = collection.ownerId !== viewer?.id;
return (
<div ref={previewerRef} css={STYLES_CONTAINER}>
{isInView && (
<div css={STYLES_PREVIEW}>
{!isLoaded && blurhash && (
<Blurhash
hash={blurhash}
style={{ position: "absolute", top: 0, left: 0 }}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
)}
<img src={selectedImage} alt="Collection preview" onLoad={handleLoading} />
</div>
)}
<div css={STYLES_CONTROLLS}>
{filePreviews.map((preview, i) => (
<button
key={preview.image}
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
selectImageByIdx(i);
}}
aria-label="Next Preview Image"
>
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
</button>
))}
</div>
<div css={STYLES_DESCRIPTION_CONTAINER}>
<div>
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
<Typography.H4 color="textBlack" nbrOflines={1}>
{collection.slatename}
</Typography.H4>
<Typography.P2 color="textGrayDark">
{nbrOfFiles} {Strings.pluralize("Object", nbrOfFiles)}
</Typography.P2>
</div>
{collection?.data?.body && (
<Typography.P2 style={{ marginTop: 4 }} nbrOflines={2} color="textGrayDark">
{collection?.data?.body}
</Typography.P2>
)}
</div>
<div css={STYLES_METRICS}>
<div css={Styles.CONTAINER_CENTERED}>
<FollowButton onFollow={showFollowButton && follow} isFollowed={isFollowed} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{followCount}
</Typography.P1>
</div>
<div css={Styles.CONTAINER_CENTERED}>
<img
css={STYLES_PROFILE_IMAGE}
src="https://slate.textile.io/ipfs/bafkreick3nscgixwfpq736forz7kzxvvhuej6kszevpsgmcubyhsx2pf7i"
alt="owner profile"
/>
<Typography.P2 style={{ marginLeft: 8 }} color="textGrayDark">
Wes Anderson
</Typography.P2>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { motion, useAnimation } from "framer-motion";
const STYLES_BUTTON_HOVER = (theme) => css`
:hover .button_path {
stroke: ${theme.system.blue};
}
`;
const STYLES_INLINE = css`
display: flex;
`;
const animate = async (controls) => {
await controls.start({ x: -2, y: 2 });
await controls.start({ x: 0, y: 0 });
};
const useMounted = (callback, depedencies) => {
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (mountedRef.current && callback) {
callback();
}
mountedRef.current = true;
}, depedencies);
};
export default function FollowButton({ onFollow, isFollowed, disabled, ...props }) {
const controls = useAnimation();
useMounted(() => {
if (isFollowed) {
animate(controls);
return;
}
}, [isFollowed]);
return (
<button
css={[Styles.BUTTON_RESET, STYLES_INLINE, !disabled && STYLES_BUTTON_HOVER]}
onClick={(e) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
if (onFollow) onFollow();
}}
>
<svg
width={20}
height={20}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<motion.path
d="M3.33334 9.66669C5.32247 9.66669 7.23012 10.4569 8.63664 11.8634C10.0432 13.2699 10.8333 15.1776 10.8333 17.1667"
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path
d="M3.33334 3.83331C6.86956 3.83331 10.2609 5.23807 12.7614 7.73856C15.2619 10.239 16.6667 13.6304 16.6667 17.1666"
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path
d="M4.16668 17.1667C4.62691 17.1667 5.00001 16.7936 5.00001 16.3333C5.00001 15.8731 4.62691 15.5 4.16668 15.5C3.70644 15.5 3.33334 15.8731 3.33334 16.3333C3.33334 16.7936 3.70644 17.1667 4.16668 17.1667Z"
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}

View File

@ -0,0 +1 @@
export { default as FollowButton } from "./FollowButton";

View File

@ -0,0 +1,50 @@
import * as React from "react";
import * as Events from "~/common/custom-events";
import * as Actions from "~/common/actions";
export const useFollowHandler = ({ collection, viewer }) => {
const followedCollection = React.useMemo(
() => viewer?.subscriptions?.find((subscription) => subscription.id === collection.id),
[]
);
const [state, setState] = React.useState({
isFollowed: !!followedCollection,
// NOTE(amine): viewer will have the hydrated state
followCount: followedCollection?.subscriberCount ?? collection.subscriberCount,
});
const handleFollowState = () => {
setState((prev) => {
if (prev.isFollowed) {
return {
isFollowed: false,
followCount: prev.followCount - 1,
};
}
return {
isFollowed: true,
followCount: prev.followCount + 1,
};
});
};
const follow = async () => {
if (!viewer) {
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
return;
}
// NOTE(amine): optimistic update
handleFollowState();
const response = await Actions.createSubscription({
slateId: collection.id,
});
if (Events.hasError(response)) {
// NOTE(amine): revert back to old state if there is an error
handleFollowState();
return;
}
};
return { follow, ...state };
};

View File

@ -0,0 +1,17 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import ImageCollectionPreview from "./ImageCollectionPreview";
import FilesCollectionPreview from "./FilesCollectionPreview";
export default function CollectionPreview({ collection, viewer }) {
const objects = collection.objects.filter((file) =>
Validations.isPreviewableImage(file.data.type)
);
if (objects.length > 0) {
return <ImageCollectionPreview collection={{ ...collection, objects }} viewer={viewer} />;
}
return <FilesCollectionPreview collection={collection} viewer={viewer} />;
}

View File

@ -6,8 +6,7 @@ import { css } from "@emotion/react";
const STYLES_CONTAINER = css`
border-radius: 4px;
box-shadow: 0 0 0 1px ${Constants.semantic.borderLight} inset,
0 0 40px 0 ${Constants.shadow.lightSmall};
box-shadow: 0 0 0 1px ${Constants.semantic.borderLight} inset, ${Constants.shadow.lightSmall};
padding: 32px;
max-width: 100%;
width: 100%;
@ -103,7 +102,6 @@ export const DataMeter = (props) => {
used
</div>
<DataMeterBar bytes={props.stats.bytes} maximumBytes={props.stats.maximumBytes} />
<div css={STYLES_NOTE}>50GB coming soon when we add email verification</div>
</div>
);
};

View File

@ -6,12 +6,11 @@ import { css } from "@emotion/react";
const STYLES_CONTAINER = css`
border-radius: 4px;
box-shadow: 0 0 0 1px ${Constants.semantic.borderLight} inset,
0 0 40px 0 ${Constants.shadow.lightSmall};
box-shadow: 0 0 0 1px ${Constants.semantic.borderLight} inset, ${Constants.shadow.lightSmall};
padding: 32px;
max-width: 100%;
width: 100%;
background-color: ${Constants.system.white};
${"" /* background-color: ${Constants.system.white}; */}
@media (max-width: ${Constants.sizes.mobile}px) {
padding: 24px;
@ -172,8 +171,6 @@ export const DataMeterDetailed = (props) => {
<div css={STYLES_DATA_METER_KEY_LABEL}>Audio</div>
</div>
</div>
<div css={STYLES_NOTE}>50GB coming soon when we add email verification</div>
{props.buttons ? <div style={{ marginTop: 24 }}>{props.buttons}</div> : null}
</div>
);

View File

@ -1,28 +1,27 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Strings from "~/common/strings";
import * as System from "~/components/system";
import * as SVG from "~/common/svg";
import * as Window from "~/common/window";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles";
import { Link } from "~/components/core/Link";
import { css } from "@emotion/react";
import { Boundary } from "~/components/system/components/fragments/Boundary";
import { PopoverNavigation } from "~/components/system/components/PopoverNavigation";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { CheckBox } from "~/components/system/components/CheckBox";
import { Table } from "~/components/core/Table";
import { FileTypeIcon } from "~/components/core/FileTypeIcon";
import { ButtonPrimary, ButtonWarning } from "~/components/system/components/Buttons";
import { GroupSelectable, Selectable } from "~/components/core/Selectable/";
import SlateMediaObjectPreview from "~/components/core/SlateMediaObjectPreview";
import FilePreviewBubble from "~/components/core/FilePreviewBubble";
import isEqual from "lodash/isEqual";
import { ConfirmationModal } from "~/components/core/ConfirmationModal";
import FilePreviewBubble from "~/components/core/FilePreviewBubble";
import ObjectPreview from "~/components/core/ObjectPreview";
import isEqual from "lodash/isEqual";
const STYLES_CONTAINER_HOVER = css`
display: flex;
:hover {
@ -163,12 +162,11 @@ const STYLES_COPY_INPUT = css`
const STYLES_IMAGE_GRID = css`
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-column-gap: 20px;
grid-row-gap: 20px;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
grid-gap: 20px 12px;
@media (max-width: ${Constants.sizes.mobile}px) {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
}
`;
@ -185,7 +183,7 @@ const STYLES_IMAGE_BOX = css`
}
:hover {
box-shadow: 0px 0px 0px 1px ${Constants.semantic.borderLight} inset,
0 0 40px 0 ${Constants.shadow.lightSmall};
${Constants.shadow.lightSmall};
}
`;
@ -562,6 +560,7 @@ export default class DataView extends React.Component {
const url = Strings.getURLfromCID(object.cid);
const title = object.filename || object.data.name;
const type = object.data.type;
console.log(e.dataTransfer, e.dataTransfer.setData);
e.dataTransfer.setData("DownloadURL", `${type}:${title}:${url}`);
};
@ -711,7 +710,7 @@ export default class DataView extends React.Component {
return (
<React.Fragment>
<GroupSelectable onSelection={this._handleDragAndSelect}>
<div css={STYLES_IMAGE_GRID} ref={this.gridWrapperEl}>
<div css={Styles.OBJECTS_PREVIEW_GRID} ref={this.gridWrapperEl}>
{this.props.items.slice(0, this.state.viewLimit).map((each, i) => {
return (
<Link
@ -729,43 +728,42 @@ export default class DataView extends React.Component {
}}
onDragEnd={this._enableDragAndDropUploadEvent}
selectableKey={i}
css={STYLES_IMAGE_BOX}
style={{
width: this.state.imageSize,
height: this.state.imageSize,
boxShadow: numChecked
? `0px 0px 0px 1px ${Constants.semantic.borderLight} inset,
0 0 40px 0 ${Constants.shadow.lightSmall}`
: "",
}}
onMouseEnter={() => this._handleCheckBoxMouseEnter(i)}
onMouseLeave={() => this._handleCheckBoxMouseLeave(i)}
>
<SlateMediaObjectPreview file={each} />
<span css={STYLES_MOBILE_HIDDEN} style={{ pointerEvents: "auto" }}>
{numChecked || this.state.hover === i || this.state.menu === each.id ? (
<React.Fragment>
<div onClick={(e) => this._handleCheckBox(e, i)}>
<CheckBox
name={i}
value={!!this.state.checked[i]}
boxStyle={{
height: 24,
width: 24,
backgroundColor: this.state.checked[i]
? Constants.system.blue
: "rgba(255, 255, 255, 0.75)",
}}
style={{
position: "absolute",
bottom: 8,
left: 8,
}}
/>
</div>
</React.Fragment>
) : null}
</span>
<div style={{ position: "relative" }}>
<ObjectPreview
viewer={this.props.viewer}
file={each}
owner={this.props.user}
onAction={this.props.onAction}
isSelected={i in this.state.checked}
/>
<span css={STYLES_MOBILE_HIDDEN} style={{ pointerEvents: "auto" }}>
{numChecked || this.state.hover === i || this.state.menu === each.id ? (
<React.Fragment>
<div
style={{ position: "absolute", zIndex: 1, left: 16, top: 16 }}
onClick={(e) => this._handleCheckBox(e, i)}
>
<CheckBox
name={i}
value={!!this.state.checked[i]}
boxStyle={{
height: 24,
width: 24,
borderRadius: "8px",
boxShadow: `0 0 0 1px ${Constants.system.white}`,
backgroundColor: this.state.checked[i]
? Constants.system.blue
: "rgba(255, 255, 255, 0.75)",
}}
/>
</div>
</React.Fragment>
) : null}
</span>
</div>
</Selectable>
</Link>
);

View File

@ -54,14 +54,14 @@ const STYLES_INPUT_ERROR = (theme) => css`
background-color: rgba(242, 242, 247, 0.5);
border: 1px solid ${theme.system.red};
&::placeholder {
color: ${theme.system.textGrayDark};
color: ${theme.semantic.textGrayDark};
}
`;
const STYLES_INPUT_SUCCESS = (theme) => css`
background-color: rgba(242, 242, 247, 0.5);
border: 1px solid ${theme.system.green};
&::placeholder {
color: ${theme.system.textGrayDark};
color: ${theme.semantic.textGrayDark};
}
`;

View File

@ -10,10 +10,15 @@ export const useFont = ({ cid }, deps) => {
const [fetchState, setFetchState] = React.useState({ loading: false, error: null });
const prevName = React.useRef(cid);
if (!window.$SLATES_LOADED_FONTS) window.$SLATES_LOADED_FONTS = [];
const alreadyLoaded = window.$SLATES_LOADED_FONTS.includes(cid);
if (typeof window !== "undefined" && !window.$SLATES_LOADED_FONTS) {
window.$SLATES_LOADED_FONTS = [];
}
const alreadyLoaded =
(typeof window !== "undefined" && window.$SLATES_LOADED_FONTS.includes(cid)) || false;
React.useEffect(() => {
if (!window) return;
if (alreadyLoaded) {
setFetchState((prev) => ({ ...prev, error: null }));
return;

View File

@ -86,7 +86,7 @@ export default function FontFrame({ cid, fallback, ...props }) {
isSettingsVisible={currentState.context.showSettings}
/>
</div>
<div style={{ position: "relative", flexGrow: 1, overflowY: "scroll" }}>
<div style={{ position: "relative", flexGrow: 1, overflowY: "auto" }}>
{isFontLoading && <FontLoader />}
<FontView
view={currentState.view}

View File

@ -75,6 +75,8 @@ export class Link extends React.Component {
css={this.props.css}
target={this.props.target}
href={this.state.href}
aria-label={this.props["aria-label"]}
title={this.props.title}
>
{this.props.children}
</a>

View File

@ -91,7 +91,7 @@ export default function LinkCard({ file }) {
<img src={image} style={{ width: "100%" }} />
</div>
<div css={Styles.VERTICAL_CONTAINER_CENTERED}>
<System.H3 style={{ marginBottom: 16, color: Constants.system.textBlack }}>
<System.H3 style={{ marginBottom: 16, color: Constants.semantic.textBlack }}>
{name}
</System.H3>
<LinkTag url={url} style={{ marginBottom: 16 }} />

View File

@ -0,0 +1,38 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import ObjectPlaceholder from "./placeholders/3D";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 25%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function Object3DPreview(props) {
return (
<ObjectPreviewPremitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<ObjectPlaceholder />
<div css={STYLES_TAG}>
<P3>3D</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,41 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import AudioPlaceholder from "./placeholders/Audio";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 23.7%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function AudioObjectPreview({ file, ...props }) {
const tag = Utilities.getFileExtension(file.filename) || "audio";
return (
<ObjectPreviewPremitive file={file} {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<AudioPlaceholder />
<div css={STYLES_TAG}>
<P3>{tag}</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,40 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import CodePlaceholder from "./placeholders/Code";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 27%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function CodeObjectPreview({ file, ...props }) {
const tag = Utilities.getFileExtension(file.filename) || "code";
return (
<ObjectPreviewPremitive file={file} {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<CodePlaceholder />
<div css={STYLES_TAG}>
<P3>{tag}</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,36 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import FilePlaceholder from "./placeholders/File";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 26%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function DefaultObjectPreview(props) {
return (
<ObjectPreviewPremitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<FilePlaceholder />
<div css={STYLES_TAG}>
<P3>FILE</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,38 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import EpubPlaceholder from "./placeholders/EPUB";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 32%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function EpubObjectPreview(props) {
return (
<ObjectPreviewPremitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<EpubPlaceholder />
<div css={STYLES_TAG}>
<P3>EPUB</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,40 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import { useFont } from "~/components/core/FontFrame/hooks";
const STYLES_TEXT_PREVIEW = (theme) => css`
position: relative;
height: 100%;
margin: 8px;
background-color: ${theme.system.white};
border-radius: 8px;
box-shadow: ${theme.shadow.large};
`;
const STYLES_LETTER = (theme) => css`
transform: translateY(-25%);
overflow: hidden;
font-size: ${theme.typescale.lvl8};
`;
export default function FontObjectPreview({ file, ...props }) {
const { fontName } = useFont({ cid: file.cid }, [file.cid]);
const tag = Utilities.getFileExtension(file.filename) || "font";
return (
<ObjectPreviewPremitive tag={tag} file={file} {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_TEXT_PREVIEW]}>
<div style={{ fontFamily: fontName }}>
<div css={STYLES_LETTER}>Aa</div>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,91 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import { AspectRatio } from "~/components/system";
import { useInView } from "~/common/hooks";
import { Blurhash } from "react-blurhash";
import { isBlurhashValid } from "blurhash";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
const STYLES_PLACEHOLDER_ABSOLUTE = css`
position: absolute;
top: 0%;
left: 0%;
width: 100%;
height: 100%;
`;
const STYLES_FLUID_CONTAINER = css`
position: relative;
width: 100%;
height: 100%;
`;
const STYLES_IMAGE = css`
object-fit: cover;
`;
const ImagePlaceholder = ({ blurhash }) => (
<div css={STYLES_PLACEHOLDER_ABSOLUTE}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
<AspectRatio ratio={186 / 302}>
<div>
<Blurhash
hash={blurhash}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
</div>
</AspectRatio>
</div>
</div>
);
export default function ImageObjectPreview({ url, file, ...props }) {
const previewerRef = React.useRef();
const [isLoading, setLoading] = React.useState(true);
const handleOnLoaded = () => setLoading(false);
const { isInView } = useInView({
ref: previewerRef,
});
const { type, coverImage } = file.data;
const tag = type.split("/")[1];
const blurhash = React.useMemo(() => {
return file.data.blurhash && isBlurhashValid(file.data.blurhash)
? file.data.blurhash
: coverImage?.data.blurhash && isBlurhashValid(coverImage?.data.blurhash)
? coverImage?.data.blurhash
: null;
}, [file]);
const shouldShowPlaceholder = isLoading && blurhash;
const imageUrl = coverImage ? Strings.getURLfromCID(coverImage?.cid) : url;
return (
<ObjectPreviewPremitive file={file} tag={tag} isImage {...props}>
<div ref={previewerRef} css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
{isInView && (
<AspectRatio ratio={186 / 302}>
{/** NOTE(amine): if it's loaded */}
<img
css={STYLES_IMAGE}
src={imageUrl}
alt={`${file.name} preview`}
onLoad={handleOnLoaded}
/>
</AspectRatio>
)}
{shouldShowPlaceholder && <ImagePlaceholder blurhash={blurhash} />}
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,38 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import KeynotePlaceholder from "./placeholders/Keynote";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 36%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function KeynoteObjectPreview(props) {
return (
<ObjectPreviewPremitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<KeynotePlaceholder />
<div css={STYLES_TAG}>
<P3>KEYNOTE</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,171 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { H4, P2, P3 } from "~/components/system/components/Typography";
import { AspectRatio } from "~/components/system";
import { LikeButton, SaveButton } from "./components";
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
import { Link } from "~/components/core/Link";
import ImageObjectPreview from "./ImageObjectPreview";
const STYLES_BACKGROUND_LIGHT = (theme) => css`
background-color: ${theme.semantic.bgLight};
box-shadow: 0 0 0 1px ${theme.semantic.bgLight};
border-radius: 8px;
`;
const STYLES_WRAPPER = css`
border-radius: 8px;
overflow: hidden;
`;
const STYLES_DESCRIPTION = (theme) => css`
box-sizing: border-box;
width: 100%;
padding: 12px 16px 12px;
position: absolute;
bottom: 0;
left: 0;
background-color: ${theme.system.white};
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 100%);
backdrop-filter: blur(75px);
-webkit-backdrop-filter: blur(75px);
}
@media (max-width: ${theme.sizes.mobile}px) {
padding: 8px;
}
`;
const STYLES_DESCRIPTION_META = css`
justify-content: space-between;
margin-top: 12px;
`;
const STYLES_REACTIONS_CONTAINER = css`
display: flex;
& > * + * {
margin-left: 32px;
}
`;
const STYLES_REACTION = css`
display: flex;
& > * + * {
margin-left: 8px;
}
`;
const STYLES_PROFILE_IMAGE = css`
display: block;
background-color: ${Constants.semantic.bgLight};
flex-shrink: 0;
object-fit: cover;
height: 20px;
width: 20px;
border-radius: 2px;
`;
const STYLES_DESCRIPTION_TAG = (theme) => css`
position: absolute;
top: -32px;
left: 12px;
text-transform: uppercase;
border: 1px solid ${theme.system.grayLight5};
background-color: ${theme.semantic.bgLight};
padding: 2px 8px;
border-radius: 4px;
`;
const STYLES_SELECTED_RING = (theme) => css`
box-shadow: 0 0 0 2px ${theme.system.blue};
`;
export default function ObjectPreviewPremitive({
children,
tag,
file,
isSelected,
viewer,
owner,
// NOTE(amine): internal prop used to display
isImage,
onAction,
}) {
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
const title = file.data.name || file.filename;
if (file?.data?.coverImage && !isImage) {
return (
<ImageObjectPreview file={file} owner={owner} isSelected={isSelected} onAction={onAction} />
);
}
const showSaveButton = viewer?.id !== file?.ownerId;
return (
<div
css={[
css({
boxShadow: `0 0 0 0px ${Constants.system.blue}`,
transition: "box-shadow 0.2s",
borderRadius: 8,
}),
isSelected && STYLES_SELECTED_RING,
]}
>
<AspectRatio ratio={295 / 248} css={STYLES_BACKGROUND_LIGHT}>
<div css={STYLES_WRAPPER}>
<AspectRatio ratio={1}>
<div>{children}</div>
</AspectRatio>
<article css={STYLES_DESCRIPTION}>
{tag && (
<div css={STYLES_DESCRIPTION_TAG}>
<P3>{tag}</P3>
</div>
)}
<H4 nbrOflines={1} color="textBlack">
{title}
</H4>
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_DESCRIPTION_META]}>
<div css={STYLES_REACTIONS_CONTAINER}>
<div css={STYLES_REACTION}>
<LikeButton onClick={like} isLiked={isLiked} />
<P2 color="textGrayDark">{likeCount}</P2>
</div>
{showSaveButton && (
<div css={STYLES_REACTION}>
<SaveButton onSave={save} isSaved={isSaved} />
<P2 color="textGrayDark">{saveCount}</P2>
</div>
)}
</div>
{owner && (
<Link
href={`/$/user/${owner.id}`}
onAction={onAction}
aria-label={`Visit ${owner.username}'s profile`}
title={`Visit ${owner.username}'s profile`}
>
<img
css={STYLES_PROFILE_IMAGE}
src={owner.data.photo}
alt={`${owner.username} profile`}
/>
</Link>
)}
</div>
</article>
</div>
</AspectRatio>
</div>
);
}

View File

@ -0,0 +1,39 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import PdfPlaceholder from "./placeholders/PDF";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
const STYLES_CONTAINER = css`
position: relative;
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 27%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function PDFObjectPreview(props) {
return (
<ObjectPreviewPremitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<PdfPlaceholder />
<div css={STYLES_TAG}>
<P3>PDF</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,76 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import TextPlaceholder from "./placeholders/Text";
const STYLES_CONTAINER = css`
position: relative;
display: flex;
height: 100%;
justify-content: center;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 26%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
const STYLES_TEXT_PREVIEW = (theme) =>
css({
height: "100%",
width: "100%",
margin: "8px",
backgroundColor: "#FFF",
borderRadius: "8px",
boxShadow: theme.shadow.large,
padding: "16px",
});
export default function TextObjectPreview({ url, file, ...props }) {
const [{ content, error }, setState] = React.useState({ content: "", error: undefined });
React.useLayoutEffect(() => {
fetch(url)
.then(async (res) => {
const content = await res.text();
setState({ content });
})
.catch((e) => {
setState({ error: e });
});
}, []);
const tag = Utilities.getFileExtension(file.filename) || "text";
return (
<ObjectPreviewPremitive tag={!error && tag} file={file} {...props}>
<div css={[STYLES_CONTAINER, error && Styles.CONTAINER_CENTERED]}>
{error ? (
<>
<TextPlaceholder />
<div css={STYLES_TAG}>
<P3>{tag}</P3>
</div>
</>
) : (
<div css={STYLES_TEXT_PREVIEW}>
<P3>{content}</P3>
</div>
)}
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,40 @@
import "isomorphic-fetch";
import * as React from "react";
import * as Styles from "~/common/styles";
import { P3 } from "~/components/system";
import { css } from "@emotion/react";
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
import VideoPlaceholder from "./placeholders/Video";
const STYLES_CONTAINER = css`
height: 100%;
`;
const STYLES_TAG = (theme) => css`
position: absolute;
text-transform: uppercase;
background-color: ${theme.semantic.bgLight};
bottom: 31.5%;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
`;
export default function VideoObjectPreview({ file, ...props }) {
const { type } = file.data;
const tag = type.split("/")[1];
return (
<ObjectPreviewPremitive file={file} {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
<VideoPlaceholder />
<div css={STYLES_TAG}>
<P3>{tag}</P3>
</div>
</div>
</ObjectPreviewPremitive>
);
}

View File

@ -0,0 +1,72 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { motion, useAnimation } from "framer-motion";
const STYLES_BUTTON_HOVER = (theme) => css`
display: flex;
:hover path {
stroke: ${theme.system.blue};
}
`;
const animate = async (controls) => {
await controls.start({ scale: 1.3, rotateY: 180, fill: "rgba(0, 132, 255, 1)" });
await controls.start({ scale: 1 });
controls.set({ rotateY: 0 });
};
const useMounted = (callback, depedencies) => {
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (mountedRef.current && callback) {
callback();
}
mountedRef.current = true;
}, depedencies);
};
export default function LikeButton({ onClick, isLiked, ...props }) {
const controls = useAnimation();
useMounted(() => {
if (isLiked) {
animate(controls);
return;
}
controls.start({ fill: "#fff", scale: 1 });
}, [isLiked]);
return (
<button
css={[Styles.BUTTON_RESET, STYLES_BUTTON_HOVER]}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onClick) onClick();
}}
>
<motion.svg
width={20}
height={20}
initial={{ fill: isLiked ? "rgba(0, 132, 255, 1)" : "rgba(0, 132, 255, 0)" }}
animate={controls}
transition={{ duration: 0.3 }}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<motion.path
d="M17.367 4.342a4.584 4.584 0 00-6.484 0L10 5.225l-.883-.883a4.584 4.584 0 00-6.484 6.483l.884.883L10 18.192l6.483-6.484.884-.883a4.584 4.584 0 000-6.483v0z"
animate={{ stroke: isLiked ? Constants.system.blue : Constants.semantic.textGrayDark }}
whileHover={{ stroke: Constants.system.blue }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
</button>
);
}

View File

@ -0,0 +1,81 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Constants from "~/common/constants";
import { css } from "@emotion/react";
import { motion } from "framer-motion";
const STYLES_BUTTON_HOVER = (theme) => css`
display: flex;
:hover .button_path {
stroke: ${theme.system.blue};
}
`;
export default function SaveButton({ onSave, isSaved, ...props }) {
return (
<button
css={[Styles.BUTTON_RESET, STYLES_BUTTON_HOVER]}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onSave) onSave();
}}
>
<motion.svg
width={20}
height={20}
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<motion.path
className="button_path"
d="M18.3333 16.3333C18.3333 16.7754 18.1577 17.1993 17.8452 17.5118C17.5326 17.8244 17.1087 18 16.6667 18H3.33332C2.8913 18 2.46737 17.8244 2.15481 17.5118C1.84225 17.1993 1.66666 16.7754 1.66666 16.3333V4.66667C1.66666 4.22464 1.84225 3.80072 2.15481 3.48816C2.46737 3.17559 2.8913 3 3.33332 3H7.49999L9.16666 5.5H16.6667C17.1087 5.5 17.5326 5.67559 17.8452 5.98816C18.1577 6.30072 18.3333 6.72464 18.3333 7.16667V16.3333Z"
stroke={Constants.semantic.textGrayDark}
animate={{
fill: isSaved ? "rgba(0, 132, 255, 1)" : "rgba(0, 132, 255, 0)",
stroke: isSaved ? Constants.system.blue : Constants.system.black,
}}
strokeWidth={1.25}
whileHover={{ stroke: Constants.system.blue }}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path
initial={{ pathLength: isSaved ? 1 : 0 }}
animate={{
pathLength: isSaved ? 1 : 0,
stroke: "#fff",
}}
style={{ y: 1, x: -0.3 }}
d="M13 9l-3.438 3.438L8 10.874"
strokeDashoffset="-1px"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
transition={{ delay: isSaved ? 0.1 : 0 }}
/>
<motion.path
className="button_path"
animate={{ y: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
d="M10 9.66665V14.6666"
stroke="#48484A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path
className="button_path"
animate={{ x: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
d="M7.5 12.1666H12.5"
stroke="#48484A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
</button>
);
}

View File

@ -0,0 +1,2 @@
export { default as LikeButton } from "./LikeButton";
export { default as SaveButton } from "./SaveButton";

View File

@ -0,0 +1,67 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Strings from "~/common/strings";
import ImageObjectPreview from "./ImageObjectPreview";
import VideoObjectPreview from "./VideoObjectPreview";
import TextObjectPreview from "./TextObjectPreview";
import PdfObjectPreview from "./PdfObjectPreview";
import EpubObjectPreview from "./EpubObjectPreview";
import AudioObjectPreview from "./AudioObjectPreview";
import KeynoteObjectPreview from "./KeynoteObjectPreview";
import DefaultObjectPreview from "./DefaultObjectPreview";
import Object3DPreview from "./3dObjectPreview";
import CodeObjectPreview from "./CodeObjectPreview";
import FontObjectPreview from "./FontObjectPreview";
const ObjectPreview = ({ file, ...props }) => {
const { type, coverImage } = file.data;
const url = Validations.isPreviewableImage(type)
? Strings.getURLfromCID(file.cid)
: Strings.getURLfromCID(coverImage?.cid);
if (Validations.isPreviewableImage(type)) {
return <ImageObjectPreview file={file} url={url} {...props} />;
}
if (type.startsWith("video/")) {
return <VideoObjectPreview file={file} url={url} {...props} />;
}
if (Validations.isPdfType(type)) {
return <PdfObjectPreview file={file} {...props} />;
}
if (type.startsWith("audio/")) {
return <AudioObjectPreview file={file} {...props} />;
}
if (type === "application/epub+zip") {
return <EpubObjectPreview file={file} {...props} />;
}
if (file.filename.endsWith(".key")) {
return <KeynoteObjectPreview file={file} {...props} />;
}
if (Validations.isCodeFile(file.filename)) {
return <CodeObjectPreview file={file} {...props} />;
}
if (Validations.isFontFile(file.filename)) {
return <FontObjectPreview file={file} url={url} {...props} />;
}
if (Validations.isMarkdown(file.filename, type)) {
return <TextObjectPreview file={file} url={url} {...props} />;
}
if (Validations.is3dFile(file.filename)) {
return <Object3DPreview file={file} {...props} />;
}
return <DefaultObjectPreview file={file} {...props} />;
};
export default React.memo(ObjectPreview);

View File

@ -0,0 +1,175 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function Object3DPlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(69 / 248) * 100 * ratio}%;
height: ${(76.65 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={69}
height={76.65}
viewBox="50 37 69 76.65"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<g filter="url(#prefix__filter0_d_3d)">
<path
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
fill="url(#prefix__paint0_linear_3d)"
/>
<path
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
stroke="url(#prefix__paint1_linear_3d)"
strokeWidth={0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<path
d="M50.535 55.68L84 75.038l33.465-19.358"
stroke="url(#prefix__paint2_linear_3d)"
strokeWidth={0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M84 113.64V75"
stroke="url(#prefix__paint3_linear_3d)"
strokeWidth={0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<mask id="prefix__a" maskUnits="userSpaceOnUse" x={49} y={36} width={70} height={78}>
<path
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
fill="url(#prefix__paint4_linear_3d)"
stroke="url(#prefix__paint5_linear_3d)"
strokeWidth={0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</mask>
<g mask="url(#prefix__a)">
<path d="M84 74.609L121 53v42.137L84 117V74.609z" fill="url(#prefix__paint6_linear_3d)" />
<path d="M84 74.602L48 53.33v41.148L84 116V74.602z" fill="url(#prefix__paint7_linear_3d)" />
</g>
<defs>
<linearGradient
id="prefix__paint0_linear_3d"
x1={84}
y1={36.675}
x2={84}
y2={113.326}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.396} stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint1_linear_3d"
x1={84}
y1={36.675}
x2={84}
y2={113.326}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.005} stopColor="#fff" />
<stop offset={1} stopColor="#D1D1D6" />
</linearGradient>
<linearGradient
id="prefix__paint2_linear_3d"
x1={84}
y1={51}
x2={84}
y2={75.038}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F2F2F7" />
<stop offset={1} stopColor="#F2F2F7" />
</linearGradient>
<linearGradient
id="prefix__paint3_linear_3d"
x1={84.5}
y1={75}
x2={84.5}
y2={113.64}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F2F2F7" />
<stop offset={1} stopColor="#fff" stopOpacity={0} />
</linearGradient>
<linearGradient
id="prefix__paint4_linear_3d"
x1={84}
y1={36.675}
x2={84}
y2={113.326}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.396} stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint5_linear_3d"
x1={84}
y1={36.675}
x2={84}
y2={113.326}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.005} stopColor="#fff" />
<stop offset={1} stopColor="#D1D1D6" />
</linearGradient>
<linearGradient
id="prefix__paint6_linear_3d"
x1={99.014}
y1={66.505}
x2={119.08}
y2={102.033}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint7_linear_3d"
x1={81.913}
y1={72.676}
x2={65.234}
y2={106.46}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<filter
id="prefix__filter0_d_3d"
x={1.25}
y={0.425}
width={165.5}
height={173.151}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dy={12} />
<feGaussianBlur stdDeviation={24} />
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
</defs>
</svg>
);
}

View File

@ -0,0 +1,57 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function AudioPlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(163 / 248) * 100 * ratio}%;
height: ${(163 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={163}
height={163}
viewBox="0 0 163 163"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<circle cx={81.5} cy={81.5} r={81.5} fill="url(#prefix__paint0_radial)" />
<path
d="M82 95.333c7.364 0 13.333-5.97 13.333-13.333 0-7.364-5.97-13.333-13.333-13.333-7.364 0-13.333 5.97-13.333 13.333 0 7.364 5.97 13.333 13.333 13.333z"
fill="#fff"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M79.333 76.667l8 5.333-8 5.333V76.667z"
fill="#C7C7CC"
stroke="#C7C7CC"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<radialGradient
id="prefix__paint0_radial"
cx={0}
cy={0}
r={1}
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(90 0 81.5) scale(87.5)"
>
<stop stopColor="#C4C4C4" />
<stop offset={1} stopColor="#C4C4C4" stopOpacity={0} />
</radialGradient>
</defs>
</svg>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,68 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function FilePlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(121 / 248) * 100 * ratio}%;
height: ${(151 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={121}
height={151}
viewBox="0 4 121 151"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<path
d="M8 157h105a8 8 0 008-8V42.314a8 8 0 00-2.343-5.657L90.343 8.343A8 8 0 0084.686 6H8a8 8 0 00-8 8v135a8 8 0 008 8z"
fill="#fff"
/>
<path
d="M73 83.333V72.667a2.667 2.667 0 00-1.333-2.307l-9.334-5.333a2.667 2.667 0 00-2.666 0l-9.334 5.333A2.668 2.668 0 0049 72.667v10.666a2.667 2.667 0 001.333 2.307l9.334 5.333a2.667 2.667 0 002.666 0l9.334-5.333A2.667 2.667 0 0073 83.333z"
fill="#E5E5EA"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M49.36 71.28L61 78.013l11.64-6.733M61 91.44V78"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<g filter="url(#prefix__filter0_d)">
<path d="M98 37h21L90 8v21a8 8 0 008 8z" fill="#D1D1D6" />
</g>
<defs>
<filter
id="prefix__filter0_d"
x={74}
y={0}
width={69}
height={69}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dx={4} dy={12} />
<feGaussianBlur stdDeviation={10} />
<feColorMatrix values="0 0 0 0 0.780392 0 0 0 0 0.780392 0 0 0 0 0.8 0 0 0 1 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
</defs>
</svg>
);
}

View File

@ -0,0 +1,107 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function KeynotePlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(183 / 248) * 100 * ratio}%;
height: ${(115 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={183}
height={115}
viewBox="49 38 183 115"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<path
d="M67 157h143a8 8 0 008-8V78.13a8 8 0 00-1.957-5.242l-24.401-28.13A8.002 8.002 0 00185.598 42H67a8 8 0 00-8 8v99a8 8 0 008 8z"
fill="#fff"
/>
<g filter="url(#prefix__filter0_d_keynote)">
<path
d="M60 154h157a8 8 0 008-8V75.323a8.002 8.002 0 00-2.182-5.49l-26.732-28.324A8.001 8.001 0 00190.268 39H60a8 8 0 00-8 8v99a8 8 0 008 8z"
fill="#fff"
/>
</g>
<g filter="url(#prefix__filter1_d_keynote)">
<path
d="M56 150.5h166.5a8 8 0 008-8V72.814a8 8 0 00-2.343-5.657l-28.814-28.814A8 8 0 00193.686 36H56a8 8 0 00-8 8v98.5a8 8 0 008 8z"
fill="#fff"
/>
</g>
<g filter="url(#prefix__filter2_d_keynote)">
<path d="M207 68h22l-30-30v22a8 8 0 008 8z" fill="#D1D1D6" />
</g>
<path
d="M119.667 87h-9.334c-.736 0-1.333.597-1.333 1.333v9.334c0 .736.597 1.333 1.333 1.333h9.334c.736 0 1.333-.597 1.333-1.333v-9.334c0-.736-.597-1.333-1.333-1.333zM137.86 87.573L132.213 97a1.33 1.33 0 00-.003 1.327 1.328 1.328 0 001.143.673h11.294a1.33 1.33 0 001.318-1.337 1.33 1.33 0 00-.178-.663l-5.647-9.427a1.332 1.332 0 00-2.28 0zM163 99.667a6.667 6.667 0 100-13.334 6.667 6.667 0 000 13.334z"
fill="#E5E5EA"
stroke="#E5E5EA"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<filter
id="prefix__filter0_d_keynote"
x={4}
y={3}
width={269}
height={211}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dy={12} />
<feGaussianBlur stdDeviation={24} />
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
<filter
id="prefix__filter1_d_keynote"
x={0}
y={0}
width={278.5}
height={210.5}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dy={12} />
<feGaussianBlur stdDeviation={24} />
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
<filter
id="prefix__filter2_d_keynote"
x={183}
y={30}
width={70}
height={70}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dx={4} dy={12} />
<feGaussianBlur stdDeviation={10} />
<feColorMatrix values="0 0 0 0 0.780392 0 0 0 0 0.780392 0 0 0 0 0.8 0 0 0 1 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
</defs>
</svg>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function VideoPlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(188 / 248) * 100 * ratio}%;
height: ${(125 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={188}
height={125}
viewBox="0 0 188 125"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<rect width={188} height={125} rx={8} fill="url(#prefix__paint0_linear)" />
<path
d="M94 73.333c7.364 0 13.333-5.97 13.333-13.333 0-7.364-5.969-13.333-13.333-13.333S80.667 52.637 80.667 60c0 7.364 5.97 13.333 13.333 13.333z"
fill="#fff"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M91.333 54.667l8 5.333-8 5.333V54.667z"
fill="#C7C7CC"
stroke="#C7C7CC"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id="prefix__paint0_linear"
x1={182}
y1={99}
x2={0}
y2={100}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#F2F2F7" />
</linearGradient>
</defs>
</svg>
);
}

View File

@ -0,0 +1,110 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Utilities from "~/common/utilities";
import * as Typography from "~/components/system/components/Typography";
import { css } from "@emotion/react";
import PdfPlaceholder from "./PDF";
import AudioPlaceholder from "./Audio";
import CodePlaceholder from "./Code";
import EpubPlaceholder from "./EPUB";
import TextPlaceholder from "./Text";
import KeynotePlaceholder from "./Keynote";
import Object3DPlaceholder from "./3D";
import FilePlaceholder from "./File";
import VideoPlaceholder from "./Video";
const STYLES_PLACEHOLDER_CONTAINER = (theme) => css`
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 64px;
width: 86px;
min-width: 64px;
border-radius: 4px;
background-color: ${theme.semantic.bgLight};
`;
const STYLES_TAG = (theme) => css`
position: absolute;
left: 50%;
bottom: 8px;
transform: translateX(-50%);
text-transform: uppercase;
border: 1px solid ${theme.system.grayLight5};
background-color: ${theme.semantic.bgLight};
padding: 2px 8px;
border-radius: 4px;
`;
const PlaceholderPremitive = ({ file, ratio }) => {
const { type } = file.data;
if (type.startsWith("video/")) {
return <VideoPlaceholder ratio={ratio} />;
}
if (Validations.isPdfType(type)) {
return <PdfPlaceholder ratio={ratio} />;
}
if (type.startsWith("audio/")) {
return <AudioPlaceholder ratio={ratio} />;
}
if (type === "application/epub+zip") {
return <EpubPlaceholder ratio={ratio} />;
}
if (file.filename.endsWith(".key")) {
return <KeynotePlaceholder ratio={ratio} />;
}
if (Validations.isCodeFile(file.filename)) {
return <CodePlaceholder ratio={ratio} />;
}
if (Validations.isMarkdown(file.filename, type)) {
return <TextPlaceholder ratio={ratio} />;
}
if (Validations.is3dFile(file.filename)) {
return <Object3DPlaceholder ratio={ratio} />;
}
return <FilePlaceholder ratio={ratio} />;
};
export default function Placeholder({ file, containerCss, ratio, showTag }) {
const { type } = file.data;
const tag = React.useMemo(() => {
if (!showTag) return false;
if (type.startsWith("video/")) return type.split("/")[1];
if (Validations.isPdfType(type)) return "pdf";
if (type.startsWith("audio/")) return Utilities.getFileExtension(file.filename) || "audio";
if (type === "application/epub+zip") return "epub";
if (file.filename.endsWith(".key")) return "keynote";
if (Validations.isCodeFile(file.filename))
return Utilities.getFileExtension(file.filename) || "code";
if (Validations.isFontFile(file.filename))
return Utilities.getFileExtension(file.filename) || "font";
if (Validations.isMarkdown(file.filename, type))
return Utilities.getFileExtension(file.filename) || "text";
if (Validations.is3dFile(file.filename)) return "3d";
return "file";
}, [file]);
return (
<div css={[STYLES_PLACEHOLDER_CONTAINER, containerCss]}>
{showTag && (
<div css={STYLES_TAG}>
<Typography.P3>{tag}</Typography.P3>
</div>
)}
<PlaceholderPremitive ratio={ratio} file={file} />
</div>
);
}

View File

@ -6,6 +6,7 @@ import * as Actions from "~/common/actions";
import * as Utilities from "~/common/utilities";
import * as Events from "~/common/custom-events";
import * as Window from "~/common/window";
import * as Styles from "~/common/styles";
import { useState } from "react";
import { Link } from "~/components/core/Link";
@ -20,9 +21,9 @@ import { LoaderSpinner } from "~/components/system/components/Loaders";
import ProcessedText from "~/components/core/ProcessedText";
import SlatePreviewBlocks from "~/components/core/SlatePreviewBlock";
import CTATransition from "~/components/core/CTATransition";
import DataView from "~/components/core/DataView";
import EmptyState from "~/components/core/EmptyState";
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
const STYLES_PROFILE_BACKGROUND = css`
background-color: ${Constants.system.white};
@ -265,6 +266,7 @@ function UserEntry({ user, button, onClick, message, checkStatus }) {
function FilesPage({
library,
user,
isOwner,
isMobile,
viewer,
@ -295,6 +297,7 @@ function FilesPage({
{library.length ? (
<DataView
key="scene-profile"
user={user}
onAction={onAction}
viewer={viewer}
isOwner={isOwner}
@ -345,7 +348,18 @@ function CollectionsPage({
style={{ margin: "0 0 24px 0" }}
/>
{slates?.length ? (
<SlatePreviewBlocks external={!viewer} slates={slates || []} onAction={onAction} />
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
{slates.map((collection) => (
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
<CollectionPreviewBlock
onAction={onAction}
collection={collection}
viewer={viewer}
owner={user}
/>
</Link>
))}
</div>
) : (
<EmptyState>
{tab === "collections" || fetched ? (
@ -436,7 +450,7 @@ function PeersPage({
) : null;
return (
<Link href={`/$/user/${relation.id}`} onAction={onAction}>
<Link key={relation.id} href={`/$/user/${relation.id}`} onAction={onAction}>
<UserEntry key={relation.id} user={relation} button={button} checkStatus={checkStatus} />
</Link>
);
@ -664,7 +678,9 @@ export default class Profile extends React.Component {
style={{ marginTop: 0, marginBottom: 32 }}
itemStyle={{ margin: "0px 16px" }}
/>
{subtab === "files" ? <FilesPage {...this.props} library={library} tab={tab} /> : null}
{subtab === "files" ? (
<FilesPage {...this.props} user={user} library={library} tab={tab} />
) : null}
{subtab === "collections" ? (
<CollectionsPage
{...this.props}

View File

@ -0,0 +1,231 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Typography from "~/components/system/components/Typography";
import * as Strings from "~/common/strings";
import { Divider } from "~/components/system/components/Divider";
import { Logo } from "~/common/logo";
import { ButtonPrimary, ButtonTertiary } from "~/components/system/components/Buttons";
import { css } from "@emotion/react";
import { LikeButton, SaveButton } from "~/components/core/ObjectPreview/components";
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
import { useFollowProfileHandler } from "~/common/hooks";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_CONTROLLS = css`
display: flex;
flex-direction: column;
align-items: flex-end;
`;
const useProfileCarrousel = ({ objects }) => {
const [selectedIdx, setSelectedIdx] = React.useState(0);
const selectBatchIdx = (idx) => setSelectedIdx(idx);
const selectedBatch = objects[selectedIdx];
return { selectBatchIdx, selectedBatch, selectedIdx };
};
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
box-sizing: border-box;
display: block;
padding: 4px 16px 4px 12px;
border: none;
background-color: unset;
div {
width: 8px;
height: 8px;
background-color: ${theme.system.gray};
border-radius: 50%;
}
`;
const STYLES_PLACEHOLDER = css`
height: 64px;
min-width: 86px;
width: 86px;
`;
const ProfilePreviewFile = ({ file, viewer }) => {
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
const title = file.data.name || file.filename;
const { body } = file.data;
return (
<div css={[Styles.HORIZONTAL_CONTAINER]}>
<ObjectPlaceholder ratio={1.1} file={file} containerCss={STYLES_PLACEHOLDER} showTag />
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
<Typography.H5 color="textBlack" nbrOflines={1}>
{title}
</Typography.H5>
<Typography.P3 nbrOflines={1} color="textGrayDark">
{body}
</Typography.P3>
<div style={{ marginTop: "auto" }} css={Styles.HORIZONTAL_CONTAINER}>
<div css={Styles.CONTAINER_CENTERED}>
<LikeButton isLiked={isLiked} onClick={like} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{likeCount}
</Typography.P1>
</div>
<div style={{ marginLeft: 48 }} css={Styles.CONTAINER_CENTERED}>
<SaveButton onSave={save} isSaved={isSaved} />
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
{saveCount}
</Typography.P1>
</div>
</div>
</div>
</div>
);
};
const STYLES_CONTAINER = (theme) => css`
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
background-color: ${theme.semantic.bgGrayLight};
`;
const STYLES_PROFILE_DESCRIPTION = (theme) => css`
background-color: ${theme.system.white};
padding: 16px;
`;
const STYLES_PROFILE_PREVIEW = (theme) => css`
height: 120px;
width: 120px;
border-radius: 8px;
object-fit: cover;
@media (max-width: ${theme.sizes.mobile}px) {
height: 104px;
width: 104px;
}
`;
const STYLES_FILES_PREVIEWS = css`
padding: 16px;
padding-right: 0px;
height: 176px;
`;
export default function ProfilePreviewBlock({ onAction, viewer, profile }) {
const filePreviews = React.useMemo(() => {
const files = profile?.objects || [];
let previews = [];
for (let i = 0; i < files.length; i++) {
const batch = [];
if (files[i * 2]) batch.push(files[i * 2]);
if (files[i * 2 + 1]) batch.push(files[i * 2 + 1]);
if (batch.length > 0) previews.push(batch);
if (previews.length === 3 || batch.length < 2) break;
}
return previews;
}, [profile]);
const { selectBatchIdx, selectedBatch, selectedIdx } = useProfileCarrousel({
objects: filePreviews,
});
const { handleFollow, isFollowing } = useFollowProfileHandler({
onAction,
viewer,
user: profile,
});
const isOwner = viewer?.id === profile.id;
const nbrOfFiles = profile?.objects?.length || 0;
const doesProfileHaveFiles = nbrOfFiles === 0;
return (
<div css={STYLES_CONTAINER}>
<div css={[STYLES_PROFILE_DESCRIPTION, Styles.HORIZONTAL_CONTAINER]}>
<img css={STYLES_PROFILE_PREVIEW} src={profile.data.photo} alt={`${profile.username}`} />
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
<div>
<Typography.H4>{profile.username}</Typography.H4>
{profile?.data?.body && (
<Typography.P2 color="gray" style={{ marginTop: 2 }}>
{profile.data.body}
</Typography.P2>
)}
</div>
<div style={{ marginTop: 8 }} css={Styles.HORIZONTAL_CONTAINER}>
<Typography.H5>
{profile.fileCount} {Strings.pluralize("file", profile.fileCount)}
</Typography.H5>
<Typography.H5 style={{ marginLeft: 16 }}>
{profile.slateCount} {Strings.pluralize("collection", profile.slateCount)}
</Typography.H5>
</div>
{!isOwner &&
(isFollowing ? (
<ButtonTertiary
style={{ marginTop: "auto", maxWidth: "91px" }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(profile.id);
}}
>
Following
</ButtonTertiary>
) : (
<ButtonPrimary
style={{ marginTop: "auto", maxWidth: "91px" }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(profile.id);
}}
>
Follow
</ButtonPrimary>
))}
</div>
</div>
<div css={STYLES_FILES_PREVIEWS} style={{ display: "flex" }}>
<div style={{ width: "100%" }}>
{!doesProfileHaveFiles ? (
selectedBatch.map((file, i) => (
<React.Fragment key={file.id}>
{i === 1 && <Divider color="grayLight4" style={{ margin: "8px 0px" }} />}
<ProfilePreviewFile file={file} viewer={viewer} />
</React.Fragment>
))
) : (
<div
style={{ height: "100%" }}
css={[Styles.CONTAINER_CENTERED, Styles.VERTICAL_CONTAINER]}
>
<Logo style={{ height: 18, marginBottom: 8 }} />
<Typography.P1 color="textGrayDark">No files in this collection</Typography.P1>
</div>
)}
</div>
{
<div css={STYLES_CONTROLLS}>
{filePreviews.map((preview, i) => (
<button
key={i}
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
selectBatchIdx(i);
}}
aria-label="Next Preview Image"
>
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
</button>
))}
</div>
}
</div>
</div>
);
}

View File

@ -21,7 +21,7 @@ const STYLES_IFRAME = (theme) => css`
width: 100%;
height: 100%;
${"" /* NOTE(Amine): lightbackground as fallback when html file doesn't have any */}
background-color: ${theme.system.wallLight};
background-color: ${theme.system.grayLight5Light};
`;
export default class SlateLinkObject extends React.Component {

View File

@ -182,7 +182,7 @@ export default class SlateMediaObjectPreview extends React.Component {
);
}
let name = (file.data?.name || file.filename).substring(0, this.charCap);
let name = (file.data?.name || file.filename || "").substring(0, this.charCap);
let extension = Strings.getFileExtension(file.filename);
if (extension && extension.length) {
extension = extension.toUpperCase();

View File

@ -125,7 +125,7 @@ const STYLES_CREATE_NEW = css`
const STYLES_BLOCK = css`
border-radius: 4px;
box-shadow: 0 0 40px 0 ${Constants.shadow.lightSmall};
box-shadow: ${Constants.shadow.lightSmall};
padding: 24px;
font-size: 12px;
text-align: left;

View File

@ -107,7 +107,7 @@ export class SecondaryTabGroup extends React.Component {
color: disabled || selected ? Constants.system.black : "rgba(0,0,0,0.25)",
cursor: disabled ? "auto" : "pointer",
...this.props.itemStyle,
backgroundColor: selected ? Constants.system.white : "transparent",
backgroundColor: selected ? Constants.semantic.bgLight : "transparent",
}}
// onClick={disabled || selected ? () => {} : () => this.props.onChange(tab.value)}
>

View File

@ -69,13 +69,6 @@ export default class SidebarFAQ extends React.Component {
</System.P1>
</div>
<div css={STYLES_GROUPING}>
<System.P1 css={STYLES_HEADER}>When will more storage be available?</System.P1>
<System.P1 css={STYLES_TEXT}>
50GB of storage free will be coming to Slate soon with email verification!
</System.P1>
</div>
<div css={STYLES_GROUPING}>
<System.P1 css={STYLES_HEADER}>Can I get involved?</System.P1>
<System.P1 css={STYLES_TEXT}>

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { css } from "@emotion/react";
const STYLES_WRAPPER = css`
position: relative;
width: 100%;
& > * {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
`;
const GET_ASPECT_STYLES = ({ minWidth, maxWith, ratio }) => css`
width: 100%;
height: 0;
padding-bottom: ${ratio * 100}%;
min-width: ${minWidth};
max-width: ${maxWith};
`;
export const AspectRatio = ({ children, minWidth, maxWith, ratio = 4 / 3, css, ...props }) => {
const aspectStyles = React.useMemo(() => {
return GET_ASPECT_STYLES({ minWidth, maxWith, ratio });
}, [minWidth, maxWith, ratio]);
//NOTE(amine): enforce single child
const child = React.Children.only(children);
return (
<div css={[STYLES_WRAPPER, aspectStyles, css]} {...props}>
{child}
</div>
);
};

View File

@ -15,9 +15,9 @@ const STYLES_BUTTON = `
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
letter-spacing: 0.2px;
font-family: ${Constants.font.semiBold};
font-size: 0.875rem;
letter-spacing: -0.006px;
font-family: ${Constants.font.medium};
transition: 200ms ease all;
overflow-wrap: break-word;
user-select: none;

View File

@ -5,6 +5,60 @@ import * as Strings from "~/common/strings";
import { css } from "@emotion/react";
const LINK_STYLES = `
font-family: ${Constants.font.text};
font-weight: 400;
text-decoration: none;
color: ${Constants.system.grayLight2};
cursor: pointer;
transition: 200ms ease color;
:hover {
color: ${Constants.system.grayDark6};
}
`;
const useColorProp = (color) =>
React.useMemo(
() => (theme) => {
if (!color) return;
if (!(color in theme.system) && !(color in theme.semantic)) {
console.warn(`${color} doesn't exist in our design system`);
return;
}
return css({ color: theme.system[color] || theme.semantic[color] });
},
[color]
);
const truncateElements = (nbrOfLines) =>
nbrOfLines &&
css`
overflow: hidden;
line-height: 1.5;
word-break: break-word;
text-overflow: ellipsis;
-webkit-line-clamp: ${nbrOfLines};
display: -webkit-box;
-webkit-box-orient: vertical;
`;
const STYLES_LINK = css`
${LINK_STYLES}
`;
const STYLES_LINK_DARK = css`
color: ${Constants.system.grayLight2};
:hover {
color: ${Constants.system.white};
}
`;
const ANCHOR = `
a {
${LINK_STYLES}
}
`;
const onDeepLink = async (object) => {
let slug = object.deeplink
.split("/")
@ -28,6 +82,7 @@ export const A = ({ href, children, dark }) => {
rel: isExternal(href) ? "external nofollow" : "",
css: Styles.LINK,
children,
// css: dark ? STYLES_LINK_DARK : STYLES_LINK,
};
// process all types of Slate links
@ -52,7 +107,7 @@ export const A = ({ href, children, dark }) => {
default: {
}
}
return <a {...linkProps} />;
return <a {...linkProps}>{children}</a>;
};
// const STYLES_H1 = css`
@ -82,68 +137,124 @@ export const A = ({ href, children, dark }) => {
// ${ANCHOR}
// `;
export const H1 = (props) => {
return <h1 {...props} css={[Styles.H1, props?.css]} />;
export const H1 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<h1 {...props} css={[Styles.H1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</h1>
);
};
export const H2 = (props) => {
return <h2 {...props} css={[Styles.H2, props?.css]} />;
export const H2 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<h2 {...props} css={[Styles.H2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</h2>
);
};
export const H3 = (props) => {
return <h3 {...props} css={[Styles.H3, props?.css]} />;
export const H3 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<h3 {...props} css={[Styles.H3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</h3>
);
};
export const H4 = (props) => {
return <h4 {...props} css={[Styles.H4, props?.css]} />;
export const H4 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<h4 {...props} css={[Styles.H4, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</h4>
);
};
export const H5 = (props) => {
return <h5 {...props} css={[Styles.H5, props?.css]} />;
export const H5 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<h5 {...props} css={[Styles.H5, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</h5>
);
};
// const STYLES_P = css`
// box-sizing: border-box;
// font-family: ${Constants.font.text};
// font-size: ${Constants.typescale.lvl1};
// line-height: 1.5;
// overflow-wrap: break-word;
// strong,
// b {
// font-family: ${Constants.font.semiBold};
// font-weight: 400;
// }
// ${ANCHOR}
// `;
export const P1 = (props) => {
return <p {...props} css={[Styles.P1, props?.css]} />;
export const P1 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.P1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const P2 = (props) => {
return <p {...props} css={[Styles.P2, props?.css]} />;
export const P2 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.P2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const P3 = (props) => {
return <p {...props} css={[Styles.P3, props?.css]} />;
export const P3 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.P3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const C1 = (props) => {
return <p {...props} css={[Styles.C1, props?.css]} />;
export const C1 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.C1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const C2 = (props) => {
return <p {...props} css={[Styles.C2, props?.css]} />;
export const C2 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.C2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const C3 = (props) => {
return <p {...props} css={[Styles.C3, props?.css]} />;
export const C3 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.C3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
export const B1 = (props) => {
return <p {...props} css={[Styles.B1, props?.css]} />;
export const B1 = ({ nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return (
<p {...props} css={[Styles.B1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
{children}
</p>
);
};
const STYLES_UL = css`
@ -167,7 +278,6 @@ export const OL = (props) => {
const STYLES_LI = css`
box-sizing: border-box;
margin-top: 12px;
strong {
font-family: ${Constants.font.semiBold};
font-weight: 400;

View File

@ -98,6 +98,8 @@ import * as SVG from "~/common/svg";
import ViewSourceLink from "~/components/system/ViewSourceLink";
import { AspectRatio } from "~/components/system/components/AspectRatio";
// NOTE(jim): Export everything.
export {
// NOTE(martina): Actions
@ -164,6 +166,7 @@ export {
LI,
A,
ViewSourceLink,
AspectRatio,
// NOTE(jim): Fragments, not meant to be used.
Boundary,
DescriptionGroup,

View File

@ -17,3 +17,5 @@ export const MIN_ARCHIVE_SIZE_BYTES = 104857600;
// NOTE(amine): 15 minutes
export const TOKEN_EXPIRATION_TIME = 2 * 60 * 60 * 1000;
export const userPreviewProperties = ["users.id", "users.data", "users.username"];

View File

@ -27,6 +27,7 @@ import deleteFilesByUserId from "~/node_common/data/methods/delete-files-by-user
import updateFileById from "~/node_common/data/methods/update-file-by-id";
import updateFilePrivacy from "~/node_common/data/methods/update-file-privacy";
import updateFilesPublic from "~/node_common/data/methods/update-files-public";
import incrementFileSavecount from "~/node_common/data/methods/increment-file-savecount";
// NOTE(martina):
// Like postgres queries
@ -122,6 +123,7 @@ export {
updateFileById,
updateFilePrivacy,
updateFilesPublic,
incrementFileSavecount,
// NOTE(martina): Like postgres queries
createLike,
deleteLikeByFile,

View File

@ -43,6 +43,7 @@ export default async ({ owner, files, saveCopy = false }) => {
}
}
}
console.log({ activityItems });
if (activityItems.length) {
const activityQuery = await DB.insert(activityItems).into("activity");

View File

@ -1,4 +1,4 @@
import * as Logging from "~/common/logging";
import * as Constants from "~/node_common/constants";
import { runQuery } from "~/node_common/data/utilities";
@ -8,124 +8,153 @@ export default async ({
earliestTimestamp,
latestTimestamp,
}) => {
let usersFollowing = following || [];
if (!following?.length || following.length < 3) {
usersFollowing.push(
...[
"f292c19f-1337-426c-8002-65e128b95096",
"708559e6-cfc9-4b82-8241-3f4e5046028d",
"1195e8bb-3f94-4b47-9deb-b30d6a6f82c4",
"231d5d53-f341-448b-9e92-0b7847c5b667",
"a280fce9-d85f-455a-9523-88f2bacd7d63",
"d909728d-f699-4474-a7d4-584b62907c53",
"20c15eab-ad33-40fd-a434-958a5f1ccb67",
"3cad78ea-01ad-4c92-8983-a97524fb9e35",
]
);
}
// const slateFiles = DB.raw(
// "coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??",
// ["files", "slate_files.createdAt", "files.id", "objects"]
// );
const slateFilesFields = ["files", "slate_files.createdAt", "files.id", "objects"];
const slateFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
const slateFields = [
"slate_table",
"slates.id",
"slates.slatename",
"slates.data",
"slates.ownerId",
"slates.isPublic",
"slates.subscriberCount",
"slates.fileCount",
...slateFilesFields,
"slates",
"slate_files",
"slate_files.slateId",
"slates.id",
"files",
"files.id",
"slate_files.fileId",
"slates.id",
];
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
const userFilesFields = ["files", "files.createdAt", "files.id", "objects"];
const userFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
const userFields = [
"user_table",
"users.id",
"users.createdAt",
"users.username",
"users.data",
"users.followerCount",
"users.fileCount",
"users.slateCount",
...userFilesFields,
"users",
"files",
"users.id",
"files.ownerId",
"users.id",
];
const userQuery = `, ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${userFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
const fileFields = [
"files_table",
"files.*",
...Constants.userPreviewProperties,
"owner",
"files",
"users",
"files.ownerId",
"users.id",
];
const fileQuery = `, ?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? on ?? = ??)`;
const selectFields = [
...slateFields,
...userFields,
...fileFields,
"activity.id",
"activity.type",
"activity.createdAt",
"slate_table",
"slate",
"files_table",
"file",
"user_table",
"user",
"owners",
"owner",
"activity",
"slate_table",
"slate_table.id",
"activity.slateId",
"user_table",
"user_table.id",
"activity.userId",
"files_table",
"files_table.id",
"activity.fileId",
"user_table",
"owners",
"owners.id",
"activity.ownerId",
];
const selectQuery = `${slateQuery} ${userQuery} ${fileQuery} SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??`;
// const selectQuery =
// "SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??";
const conditionFields = ["activity.ownerId", usersFollowing, "activity.slateId", subscriptions];
return await runQuery({
label: "GET_ACTIVITY_FOR_USER_ID",
queryFn: async (DB) => {
const users = () => DB.raw("row_to_json(??) as ??", ["users", "owner"]);
const slates = () => DB.raw("row_to_json(??) as ??", ["slates", "slate"]);
const files = () => DB.raw("row_to_json(??) as ??", ["files", "file"]);
let query;
if (earliestTimestamp) {
//NOTE(martina): for pagination, fetching the "next 100" results
let date = new Date(earliestTimestamp);
let s = date.getSeconds();
if (s < 0) {
s = 60 + s;
}
date.setSeconds(s - 1);
query = await DB.select(
"activity.id",
"activity.type",
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.whereRaw("?? < ? and ?? = ? and (?? = any(?) or ?? = any(?))", [
"activity.createdAt",
date.toISOString(),
"activity.type",
"CREATE_SLATE_OBJECT",
"activity.ownerId",
following,
"activity.slateId",
subscriptions,
])
// .where("activity.type", "CREATE_SLATE_OBJECT")
// .where("activity.createdAt", "<", date.toISOString())
// .whereIn("activity.ownerId", following)
// .orWhereIn("activity.slateId", subscriptions)
.orderBy("activity.createdAt", "desc")
.limit(96);
date.setSeconds(date.getSeconds() - 1);
query = await DB.raw(
`${selectQuery} WHERE (?? = ANY(?) OR ?? = ANY(?)) AND ?? < '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
[...selectFields, ...conditionFields, "activity.createdAt", "activity.createdAt"]
);
} else if (latestTimestamp) {
//NOTE(martina): for fetching new updates since the last time they loaded
let date = new Date(latestTimestamp);
date.setSeconds(date.getSeconds() + 1);
query = await DB.select(
"activity.id",
"activity.type",
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.whereRaw("?? > ? and ?? = ? and (?? = any(?) or ?? = any(?))", [
"activity.createdAt",
date.toISOString(),
"activity.type",
"CREATE_SLATE_OBJECT",
"activity.ownerId",
following,
"activity.slateId",
subscriptions,
])
// .where("activity.createdAt", ">", date.toISOString())
// .where("activity.type", "CREATE_SLATE_OBJECT")
// .whereIn("activity.ownerId", following)
// .orWhereIn("activity.slateId", subscriptions)
.orderBy("activity.createdAt", "desc")
.limit(96);
query = await DB.raw(
`${selectQuery} WHERE (?? = ANY(?) OR ?? = ANY(?)) AND ?? > '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
[...selectFields, ...conditionFields, "activity.createdAt", "activity.createdAt"]
);
} else {
//NOTE(martina): for the first fetch they make, when they have not loaded any explore events yet
query = await DB.select(
"activity.id",
"activity.type",
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.whereRaw("?? = ? and (?? = any(?) or ?? = any(?))", [
"activity.type",
"CREATE_SLATE_OBJECT",
"activity.ownerId",
following,
"activity.slateId",
subscriptions,
])
// .where("activity.type", "CREATE_SLATE_OBJECT")
// .whereIn("activity.ownerId", following)
// .orWhereIn("activity.slateId", subscriptions)
.orderBy("activity.createdAt", "desc")
.limit(96);
query = await DB.raw(
`${selectQuery} WHERE ?? = ANY(?) OR ?? = ANY(?) ORDER BY ?? DESC LIMIT 100`,
[...selectFields, ...conditionFields, "activity.createdAt"]
);
}
if (!query) {
return [];
if (query?.rows) {
query = query.rows;
} else {
query = [];
}
return JSON.parse(JSON.stringify(query));
},
errorFn: async (e) => {
Logging.error({
errorFn: async () => {
console.log({
error: true,
decorator: "GET_ACTIVITY_FOR_USER_ID",
});

View File

@ -1,92 +1,133 @@
import * as Logging from "~/common/logging";
import * as Constants from "~/node_common/constants";
import { runQuery } from "~/node_common/data/utilities";
export default async ({ earliestTimestamp, latestTimestamp }) => {
const slateFilesFields = ["files", "slate_files.createdAt", "files.id", "objects"];
const slateFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
const slateFields = [
"slate_table",
"slates.id",
"slates.slatename",
"slates.data",
"slates.ownerId",
"slates.isPublic",
"slates.subscriberCount",
"slates.fileCount",
...slateFilesFields,
"slates",
"slate_files",
"slate_files.slateId",
"slates.id",
"files",
"files.id",
"slate_files.fileId",
"slates.id",
];
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
const userFilesFields = ["files", "files.createdAt", "files.id", "objects"];
const userFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
const userFields = [
"user_table",
"users.id",
"users.createdAt",
"users.username",
"users.data",
"users.followerCount",
"users.fileCount",
"users.slateCount",
...userFilesFields,
"users",
"files",
"users.id",
"files.ownerId",
"users.id",
];
const userQuery = `, ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${userFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
const fileFields = [
"files_table",
"files.*",
...Constants.userPreviewProperties,
"owner",
"files",
"users",
"files.ownerId",
"users.id",
];
const fileQuery = `, ?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? on ?? = ??)`;
const selectFields = [
...slateFields,
...userFields,
...fileFields,
"activity.id",
"activity.type",
"activity.createdAt",
"slate_table",
"slate",
"files_table",
"file",
"user_table",
"user",
"owners",
"owner",
"activity",
"slate_table",
"slate_table.id",
"activity.slateId",
"user_table",
"user_table.id",
"activity.userId",
"files_table",
"files_table.id",
"activity.fileId",
"user_table",
"owners",
"owners.id",
"activity.ownerId",
];
const selectQuery = `${slateQuery} ${userQuery} ${fileQuery} SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??`;
return await runQuery({
label: "GET_EXPLORE",
queryFn: async (DB) => {
const users = () => DB.raw("row_to_json(??) as ??", ["users", "owner"]);
const slates = () => DB.raw("row_to_json(??) as ??", ["slates", "slate"]);
const files = () => DB.raw("row_to_json(??) as ??", ["files", "file"]);
let query;
if (earliestTimestamp) {
//NOTE(martina): for pagination, fetching the "next 100" results
let date = new Date(earliestTimestamp);
let s = date.getSeconds();
if (s < 0) {
s = 60 + s;
}
date.setSeconds(s - 1);
query = await DB.select(
"activity.id",
"activity.type",
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.where("activity.createdAt", "<", date.toISOString())
.where("activity.type", "CREATE_SLATE_OBJECT")
.orderBy("activity.createdAt", "desc")
.limit(96);
date.setSeconds(date.getSeconds() - 1);
query = await DB.raw(
`${selectQuery} WHERE ?? < '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
[...selectFields, "activity.createdAt", "activity.createdAt"]
);
} else if (latestTimestamp) {
//NOTE(martina): for fetching new updates since the last time they loaded
let date = new Date(latestTimestamp);
date.setSeconds(date.getSeconds() + 1);
query = await DB.select(
"activity.id",
"activity.type",
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.where("activity.createdAt", ">", date.toISOString())
.where("activity.type", "CREATE_SLATE_OBJECT")
.orderBy("activity.createdAt", "desc")
.limit(96);
query = await DB.raw(
`${selectQuery} WHERE ?? > '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
[...selectFields, "activity.createdAt", "activity.createdAt"]
);
} else {
//NOTE(martina): for the first fetch they make, when they have not loaded any explore events yet
query = await DB.select(
"activity.id",
"activity.type",
query = await DB.raw(`${selectQuery} ORDER BY ?? DESC LIMIT 100`, [
...selectFields,
"activity.createdAt",
"activity.slateId",
// users(),
// slates(),
files()
)
.from("activity")
// .join("users", "users.id", "=", "activity.ownerId")
.leftJoin("files", "files.id", "=", "activity.fileId")
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
.where("activity.type", "CREATE_SLATE_OBJECT")
.orderBy("activity.createdAt", "desc")
.limit(96);
]);
}
if (!query || query.error) {
return [];
if (query?.rows) {
query = query.rows;
} else {
query = [];
}
return JSON.parse(JSON.stringify(query));
},
errorFn: async (e) => {
Logging.error({
errorFn: async () => {
console.log({
error: true,
decorator: "GET_EXPLORE",
});

View File

@ -11,7 +11,7 @@ export default async ({ ownerId }) => {
const query = await DB.select(...Serializers.fileProperties)
.from("files")
.join("likes", "likes.fileId", "=", "files.id")
.where({ "likes.userId": ownerId, "files.isPublic": true })
.where({ "likes.userId": ownerId })
.groupBy("files.id");
if (!query || query.error) {

View File

@ -0,0 +1,23 @@
import { runQuery } from "~/node_common/data/utilities";
//NOTE(martina): remember to include an ownerId for the file
export default async ({ id }) => {
return await runQuery({
label: "CREATE_FILE",
queryFn: async (DB) => {
const query = await DB.from("files").where("id", id).increment("saveCount", 1);
if (!query) {
return null;
}
return JSON.parse(JSON.stringify(query));
},
errorFn: async () => {
return {
error: true,
decorator: "CREATE_FILE",
};
},
});
};

View File

@ -44,7 +44,7 @@ const websocketSend = async (type, data) => {
export const hydratePartial = async (
id,
{ viewer, slates, keys, library, subscriptions, following, followers }
{ viewer, slates, keys, library, subscriptions, following, followers, likes }
) => {
if (!id) return;
@ -57,6 +57,8 @@ export const hydratePartial = async (
id,
includeFiles: true,
});
update.libraryCids =
user?.library?.reduce((acc, file) => ({ ...acc, [file.cid]: file }), {}) || {};
} else {
user = await Data.getUserById({
id,
@ -103,6 +105,11 @@ export const hydratePartial = async (
update.followers = followers;
}
if (likes) {
const likes = await Data.getLikesByUserId({ ownerId: id });
update.likes = likes;
}
websocketSend("UPDATE", update);
};
@ -155,6 +162,9 @@ export const getById = async ({ id }) => {
const subscriptions = await Data.getSubscriptionsByUserId({ ownerId: id });
const following = await Data.getFollowingByUserId({ ownerId: id });
const followers = await Data.getFollowersByUserId({ userId: id });
const likes = await Data.getLikesByUserId({ ownerId: id });
const libraryCids =
user?.library?.reduce((acc, file) => ({ ...acc, [file.cid]: file }), {}) || {};
let cids = {};
let bytes = 0;
@ -178,7 +188,7 @@ export const getById = async ({ id }) => {
} else if (each.data.type && each.data.type.startsWith("application/pdf")) {
pdfBytes += each.data.size;
}
let coverImage = each.data.coverImage;
let { coverImage } = each.data;
if (coverImage && !cids[coverImage.cid]) {
imageBytes += coverImage.data.size;
cids[coverImage.cid] = true;
@ -223,6 +233,8 @@ export const getById = async ({ id }) => {
subscriptions,
following,
followers,
likes,
libraryCids,
};
return viewer;

View File

@ -14,7 +14,7 @@ export const getServerSideProps = async ({ query }) => {
// },
// };
return {
props: { ...JSON.parse(JSON.stringify(query)) },
props: JSON.parse(JSON.stringify({ ...query })),
};
};

View File

@ -1,6 +1,7 @@
import * as Data from "~/node_common/data";
import * as Utilities from "~/node_common/utilities";
import * as RequestUtilities from "~/node_common/request-utilities";
import * as ViewerManager from "~/node_common/managers/viewer";
export default async (req, res) => {
const userInfo = await RequestUtilities.checkAuthorizationInternal(req, res);
@ -11,7 +12,9 @@ export default async (req, res) => {
return res.status(500).send({ decorator: "SERVER_LIKE_FILE_NO_FILE_PROVIDED", error: true });
}
const existingResponse = await Data.getLikeByFile({ userId: user.id, fileId: file.id });
const fileId = req.body.data.id;
const existingResponse = await Data.getLikeByFile({ userId: user.id, fileId });
if (existingResponse && existingResponse.error) {
return res.status(500).send({
@ -26,7 +29,7 @@ export default async (req, res) => {
if (existingResponse) {
response = await Data.deleteLikeByFile({
userId: user.id,
fileId: file.id,
fileId,
});
if (!response) {
@ -37,7 +40,7 @@ export default async (req, res) => {
return res.status(500).send({ decorator: "SERVER_UNLIKE_FILE_FAILED", error: true });
}
} else {
response = await Data.createLike({ userId: user.id, fileId: file.id });
response = await Data.createLike({ userId: user.id, fileId });
if (!response) {
return res.status(404).send({ decorator: "SERVER_LIKE_FILE_FAILED", error: true });
@ -48,6 +51,8 @@ export default async (req, res) => {
}
}
ViewerManager.hydratePartial(id, { likes: true });
return res.status(200).send({
decorator: "SERVER_LIKE_FILE",
data: response,

View File

@ -17,7 +17,7 @@ export default async (req, res) => {
let decorator = "SERVER_SAVE_COPY";
let { buckets, bucketKey, bucketRoot, bucketName } = await Utilities.getBucketAPIFromUserToken({
let { buckets, bucketKey, bucketRoot } = await Utilities.getBucketAPIFromUserToken({
user,
});
@ -28,7 +28,7 @@ export default async (req, res) => {
});
}
const files = req.body.data.files;
const { files } = req.body.data;
if (!files?.length) {
return res.status(400).send({
decorator: "SERVER_SAVE_COPY_NO_CIDS",
@ -57,7 +57,7 @@ export default async (req, res) => {
filteredFiles = filteredFiles
.filter((file) => foundCids.includes(file.cid)) //NOTE(martina): make sure the file being copied exists
.map(({ createdAt, id, likeCount, downloadCount, saveCount, ...keepAttrs }) => {
.map(({ createdAt, likeCount, downloadCount, saveCount, ...keepAttrs }) => {
//NOTE(martina): remove the old file's id, ownerId, createdAt, and privacy so new fields can be used
return { ...keepAttrs, isPublic: slate?.isPublic || false };
});
@ -70,15 +70,16 @@ export default async (req, res) => {
continue;
}
const { id, ...rest } = file;
let response = await Utilities.addExistingCIDToData({
buckets,
key: bucketKey,
path: bucketRoot.path,
cid: file.cid,
cid: rest.cid,
});
Data.incrementFileSavecount({ id });
if (response && !response.error) {
copiedFiles.push(file);
copiedFiles.push(rest);
}
}

View File

@ -5,17 +5,20 @@ import * as Window from "~/common/window";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as Events from "~/common/custom-events";
import * as Styles from "~/common/styles";
import * as ActivityUtilities from "~/common/activity-utilities";
import { GlobalCarousel } from "~/components/system/components/GlobalCarousel";
import { css } from "@emotion/react";
import { TabGroup, PrimaryTabGroup, SecondaryTabGroup } from "~/components/core/TabGroup";
import { SecondaryTabGroup } from "~/components/core/TabGroup";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { Link } from "~/components/core/Link";
import EmptyState from "~/components/core/EmptyState";
import ScenePage from "~/components/core/ScenePage";
import SlateMediaObjectPreview from "~/components/core/SlateMediaObjectPreview";
import ObjectPreview from "~/components/core/ObjectPreview";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
import ActivityObjectPreview from "~/components/core/ActivityObjectPreview";
const STYLES_LOADER = css`
display: flex;
@ -30,11 +33,9 @@ const STYLES_IMAGE_BOX = css`
position: relative;
box-shadow: ${Constants.shadow.lightSmall};
margin: 10px;
:hover {
box-shadow: ${Constants.shadow.lightMedium};
}
@media (max-width: ${Constants.sizes.mobile}px) {
overflow: hidden;
border-radius: 8px;
@ -78,48 +79,23 @@ const STYLES_GRADIENT = css`
position: absolute;
top: 0px;
left: 0px;
@media (max-width: ${Constants.sizes.mobile}px) {
overflow: hidden;
border-radius: 0px 0px 8px 8px;
}
`;
const STYLES_ACTIVITY_GRID = css`
margin: -10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
@media (max-width: ${Constants.sizes.mobile}px) {
margin-top: 24px;
}
`;
class ActivitySquare extends React.Component {
state = {
showText: false,
};
render() {
const item = this.props.item;
const size = this.props.size;
// const isImage =
// Validations.isPreviewableImage(item.file.data.type) || !!item.file.data.coverImage;
const { item } = this.props;
return (
<div
css={STYLES_IMAGE_BOX}
style={{ width: size, height: size }}
onMouseEnter={() => this.setState({ showText: true })}
onMouseLeave={() => this.setState({ showText: false })}
>
<SlateMediaObjectPreview
file={item.file}
centeredImage
// iconOnly
style={{ border: "none" }}
imageStyle={{ border: "none" }}
/>
<div>
<ObjectPreview file={item.file} />
</div>
);
}
@ -154,15 +130,7 @@ const ActivityRectangle = ({ item, width, height }) => {
let numObjects = item.slate?.objects?.length || 0;
return (
<div css={STYLES_IMAGE_BOX} style={{ width, height }}>
{file ? (
<SlateMediaObjectPreview
file={file}
centeredImage
iconOnly
style={{ border: "none" }}
imageStyle={{ border: "none" }}
/>
) : null}
{file ? <ObjectPreview file={file} /> : null}
<div css={STYLES_GRADIENT} />
<div css={STYLES_TEXT_AREA}>
<div
@ -247,9 +215,20 @@ export default class SceneActivity extends React.Component {
}
};
getTab = () => {
if (!this.props.viewer) {
return "explore";
}
return this.props.page.params?.tab || "explore";
};
fetchActivityItems = async (update = false) => {
if (this.state.loading === "loading") return;
let tab = this.getTab();
<<<<<<< HEAD
=======
>>>>>>> e3ef4c62... added activity grouping function
const isExplore = tab === "explore";
this.setState({ loading: "loading" });
let activity;
@ -282,13 +261,11 @@ export default class SceneActivity extends React.Component {
}
let newItems = response.data || [];
newItems = ActivityUtilities.processActivity(newItems);
if (update) {
activity.unshift(...newItems);
this.counter = 0;
activity = this.formatActivity(activity);
} else {
newItems = this.formatActivity(newItems);
activity.push(...newItems);
}
@ -304,52 +281,11 @@ export default class SceneActivity extends React.Component {
}
};
formatActivity = (userActivity) => {
let activity = [];
for (let item of userActivity) {
// if (item.slate && !item.slate.isPublic) {
// continue;
// }
if (item.type === "CREATE_SLATE_OBJECT") {
//&& item.slate && item.file
activity.push(item);
} else if (item.type === "CREATE_SLATE" && item.slate) {
activity.push(item);
}
}
return activity; //NOTE(martina): because now it's only things of CREATE_SLATE_OBJECT type, so all square and don't need reordering
//NOTE(martina): rearrange order to always get an even row of 6 squares
//TODO(martina): improve this. will fail if there are no more squares left to "swap" with at the end, and you'll end up wtih an empty space
// let activity = userActivity || [];
// for (let i = 0; i < activity.length; i++) {
// let item = activity[i];
// if (item.type === "CREATE_SLATE") {
// this.counter += 2;
// } else if (item.type === "CREATE_SLATE_OBJECT") {
// this.counter += 1;
// }
// if (this.counter === 6) {
// this.counter = 0;
// } else if (this.counter > 6) {
// let j = i - 1;
// while (activity[j].type !== "CREATE_SLATE_OBJECT") {
// j -= 1;
// }
// let temp = activity[j];
// activity[j] = activity[i];
// activity[i] = temp;
// this.counter = 0;
// i -= 1;
// }
// }
// return activity;
};
calculateWidth = () => {
let windowWidth = window.innerWidth;
let imageSize;
if (windowWidth < Constants.sizes.mobile) {
imageSize = windowWidth - 2 * 24; //(windowWidth - 2 * 24 - 20) / 2;
imageSize = windowWidth - 2 * 24;
} else {
imageSize = (windowWidth - 2 * 56 - 5 * 20) / 6;
}
@ -364,7 +300,6 @@ export default class SceneActivity extends React.Component {
render() {
let tab = this.getTab();
let activity;
if (this.props.viewer) {
activity =
tab === "activity" ? this.props.viewer?.activity || [] : this.props.viewer?.explore || [];
@ -372,23 +307,12 @@ export default class SceneActivity extends React.Component {
activity = this.state.explore || [];
}
let items = activity
.filter((item) => item.type === "CREATE_SLATE_OBJECT")
.map((item) => {
return {
...item.file,
slateId: item.slateId,
// slate: item.slate,
// owner: item.owner?.username,
};
});
return (
<WebsitePrototypeWrapper
title={`${this.props.page.pageTitle} • Slate`}
url={`${Constants.hostname}${this.props.page.pathname}`}
>
<ScenePage>
<ScenePage style={{ backgroundColor: "#F2F2F7" }}>
{this.props.viewer && (
<SecondaryTabGroup
tabs={[
@ -413,12 +337,11 @@ export default class SceneActivity extends React.Component {
this.setState({ carouselIndex: index });
}}
isMobile={this.props.isMobile}
// params={this.props.page.params}
isOwner={false}
/>
{activity.length ? (
<div>
<div css={STYLES_ACTIVITY_GRID}>
<div css={Styles.OBJECTS_PREVIEW_GRID}>
{activity.map((item, i) => {
if (item.type === "CREATE_SLATE") {
return (
@ -426,25 +349,10 @@ export default class SceneActivity extends React.Component {
redirect
key={i}
disabled={this.props.isMobile ? false : true}
// params={
// this.props.isMobile
// ? null
// : { ...this.props.page.params, cid: item.file.cid }
// }
href={`/$/slate/${item.slateId}`}
onAction={this.props.onAction}
onClick={() => this.setState({ carouselIndex: i })}
>
{/* <span
key={item.id}
onClick={() =>
this.props.onAction({
type: "NAVIGATE",
value: "NAV_SLATE",
data: item.slate,
})
}
> */}
<ActivityRectangle
width={
this.props.isMobile
@ -454,7 +362,6 @@ export default class SceneActivity extends React.Component {
height={this.state.imageSize}
item={item}
/>
{/* </span> */}
</Link>
);
} else if (item.type === "CREATE_SLATE_OBJECT") {
@ -463,23 +370,9 @@ export default class SceneActivity extends React.Component {
redirect
key={i}
disabled={this.props.isMobile ? false : true}
// params={
// this.props.isMobile
// ? null
// : { ...this.props.page.params, cid: item.file.cid }
// }
href={`/$/slate/${item.slateId}?cid=${item.file.cid}`}
onAction={this.props.onAction}
onClick={() => this.setState({ carouselIndex: i })}
// onClick={
// this.props.isMobile
// ? () => {}
// : () =>
// Events.dispatchCustomEvent({
// name: "slate-global-open-carousel",
// detail: { index: this.getItemIndexById(items, item) },
// })
// }
>
<ActivitySquare
size={this.state.imageSize}

View File

@ -0,0 +1,112 @@
import * as React from "react";
import * as Events from "~/common/custom-events";
import * as ActivityUtilities from "~/common/activity-utilities";
const updateExploreFeed = async ({ viewer, state, onAction, setState, update }) => {
const currentItems = viewer?.explore?.items || state?.explore?.items || [];
const response = await ActivityUtilities.fetchExploreItems({ currentItems, update });
if (Events.hasError(response)) return;
const newItems = response.data;
const currentFeed = viewer?.explore?.feed || state?.explore?.feed || [];
const newFeed = await ActivityUtilities.processActivity(newItems);
const newState = {
items: currentItems.concat(newItems),
feed: currentFeed.concat(newFeed),
shouldFetchMore: newItems.length > 0,
};
if (viewer) {
onAction({ type: "UPDATE_VIEWER", viewer: { explore: newState } });
return;
}
setState((prev) => ({ ...prev, explore: newState }));
};
const updateActivityFeed = async ({ viewer, onAction, update }) => {
const currentItems = viewer?.activity?.items || [];
const response = await ActivityUtilities.fetchActivityItems({ currentItems, viewer, update });
if (Events.hasError(response)) return;
const newItems = response.data;
const currentFeed = viewer?.activity?.feed || [];
const newFeed = ActivityUtilities.processActivity(newItems);
onAction({
type: "UPDATE_VIEWER",
viewer: {
activity: {
feed: currentFeed.concat(newFeed),
items: currentItems.concat(newItems),
shouldFetchMore: newItems.length > 0,
},
},
});
};
// NOTE(amine): get the state for the selected tab.
const getState = (viewer, state, tab) => {
if (!viewer) return state.explore || [];
if (tab === "explore") {
return viewer?.explore || {};
}
return viewer?.activity || {};
};
const getTab = (page, viewer) => {
if (page.params?.tab) return page.params?.tab;
if (viewer?.subscriptions?.length || viewer?.following?.length) {
return "activity";
}
return "explore";
};
export function useActivity({ page, viewer, onAction }) {
const [state, setState] = React.useState({
explore: {
feed: [],
items: [],
shouldFetchMore: true,
},
loading: {
explore: false,
activity: false,
},
});
const tab = getTab(page, viewer);
const updateFeed = React.useCallback(
async (update) => {
const currentState = getState(viewer, state, tab);
const { shouldFetchMore } = currentState || {};
if (typeof shouldFetchMore === "boolean" && !shouldFetchMore) {
return;
}
if (state.loading[tab]) return;
setState((prev) => ({ ...prev, loading: { ...prev.loading, [tab]: true } }));
if (viewer && tab === "activity") {
await updateActivityFeed({ viewer, onAction, update });
} else {
await updateExploreFeed({ viewer, onAction, state, setState, update });
}
setState((prev) => ({ ...prev, loading: { ...prev.loading, [tab]: false } }));
},
[tab, onAction, state, viewer]
);
const { feed = [] } = getState(viewer, state, tab);
React.useEffect(() => {
if (feed && feed?.length !== 0) return;
updateFeed(true);
}, [tab]);
return { updateFeed, feed, tab, isLoading: state.loading };
}

View File

@ -0,0 +1,86 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { SecondaryTabGroup } from "~/components/core/TabGroup";
import { LoaderSpinner } from "~/components/system/components/Loaders";
import { useIntersection } from "common/hooks";
import { useActivity } from "./hooks";
import ScenePage from "~/components/core/ScenePage";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
import ActivityGroup from "~/components/core/ActivityGroup";
const STYLES_GROUPS_CONTAINER = css`
margin-top: 32px;
& > * + * {
margin-top: 32px;
}
`;
const STYLES_LOADING_CONTAINER = css`
height: 48px;
margin-top: 32px;
${Styles.CONTAINER_CENTERED}
`;
const STYLES_LOADER = css`
display: flex;
align-items: center;
justify-content: center;
height: calc(100vh - 400px);
width: 100%;
`;
export default function SceneActivity({ page, viewer, external, onAction }) {
const { feed, tab, isLoading, updateFeed } = useActivity({
page,
viewer,
onAction,
});
const divRef = React.useRef();
useIntersection({
ref: divRef,
onIntersect: () => {
if (feed?.length === 0 || isLoading[tab]) return;
updateFeed();
},
});
return (
<WebsitePrototypeWrapper
title={`${page.pageTitle} • Slate`}
url={`${Constants.hostname}${page.pathname}`}
>
<ScenePage>
{viewer && (
<SecondaryTabGroup
tabs={[
{ title: "My network", value: { tab: "activity" } },
{ title: "Explore", value: { tab: "explore" } },
]}
value={tab}
onAction={onAction}
style={{ marginTop: 0 }}
/>
)}
<div css={STYLES_GROUPS_CONTAINER}>
{feed?.map((group) => (
<ActivityGroup
key={group.id}
viewer={viewer}
external={external}
onAction={onAction}
group={group}
/>
))}
</div>
<div ref={divRef} css={feed?.length ? STYLES_LOADING_CONTAINER : STYLES_LOADER}>
{isLoading[tab] && <LoaderSpinner style={{ height: 32, width: 32 }} />}
</div>
</ScenePage>
</WebsitePrototypeWrapper>
);
}

View File

@ -466,6 +466,7 @@ export default class SceneFilesFolder extends React.Component {
key="scene-files-folder"
onAction={this.props.onAction}
viewer={this.props.viewer}
user={this.props.viewer}
items={files}
view={tab}
resources={this.props.resources}

View File

@ -2,15 +2,17 @@ import * as React from "react";
import * as SVG from "~/common/svg";
import * as Events from "~/common/custom-events";
import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { TabGroup, PrimaryTabGroup, SecondaryTabGroup } from "~/components/core/TabGroup";
import { ButtonSecondary } from "~/components/system/components/Buttons";
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
import { Link } from "~/components/core/Link";
import ScenePage from "~/components/core/ScenePage";
import ScenePageHeader from "~/components/core/ScenePageHeader";
import SlatePreviewBlocks from "~/components/core/SlatePreviewBlock";
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
import SquareButtonGray from "~/components/core/SquareButtonGray";
import EmptyState from "~/components/core/EmptyState";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
@ -42,57 +44,78 @@ export default class SceneSlates extends React.Component {
url={`${Constants.hostname}${this.props.page.pathname}`}
>
<ScenePage>
<div style={{ display: "flex", alignItems: "center", marginBottom: 24 }}>
<SecondaryTabGroup
tabs={[
{ title: "My Collections", value: { tab: "collections" } },
{ title: "Subscribed", value: { tab: "subscribed" } },
]}
value={tab}
onAction={this.props.onAction}
style={{ margin: 0 }}
/>
<SquareButtonGray onClick={this._handleAdd} style={{ marginLeft: 16 }}>
<SVG.Plus height="16px" />
</SquareButtonGray>
</div>
{tab === "collections" ? (
this.props.viewer.slates?.length ? (
<SlatePreviewBlocks
isOwner
slates={this.props.viewer.slates}
username={this.props.viewer.username}
<div>
<div style={{ display: "flex", alignItems: "center", marginBottom: 24 }}>
<SecondaryTabGroup
tabs={[
{ title: "My Collections", value: { tab: "collections" } },
{ title: "Subscribed", value: { tab: "subscribed" } },
]}
value={tab}
onAction={this.props.onAction}
style={{ margin: 0 }}
/>
) : (
<EmptyState>
<FileTypeGroup />
<div style={{ marginTop: 24 }}>
Use collections to create mood boards, share files, and organize research.
<SquareButtonGray onClick={this._handleAdd} style={{ marginLeft: 16 }}>
<SVG.Plus height="16px" />
</SquareButtonGray>
</div>
{tab === "collections" ? (
this.props.viewer.slates?.length ? (
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
{this.props.viewer.slates.map((slate) => (
<Link
key={slate.id}
href={`/$/slate/${slate.id}`}
onAction={this.props.onAction}
>
<CollectionPreviewBlock
key={slate.id}
collection={slate}
viewer={this.props.viewer}
/>
</Link>
))}
</div>
<ButtonSecondary onClick={this._handleAdd} style={{ marginTop: 32 }}>
Create collection
</ButtonSecondary>
</EmptyState>
)
) : null}
) : (
<EmptyState>
<FileTypeGroup />
<div style={{ marginTop: 24 }}>
Use collections to create mood boards, share files, and organize research.
</div>
<ButtonSecondary onClick={this._handleAdd} style={{ marginTop: 32 }}>
Create collection
</ButtonSecondary>
</EmptyState>
)
) : null}
{tab === "subscribed" ? (
subscriptions && subscriptions.length ? (
<SlatePreviewBlocks
slates={subscriptions}
username={null}
onAction={this.props.onAction}
/>
) : (
<EmptyState>
You can follow any public collections on the network.
<ButtonSecondary onClick={this._handleSearch} style={{ marginTop: 32 }}>
Browse collections
</ButtonSecondary>
</EmptyState>
)
) : null}
{tab === "subscribed" ? (
subscriptions && subscriptions.length ? (
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
{subscriptions.map((slate) => (
<Link
key={slate.id}
href={`/$/slate/${slate.id}`}
onAction={this.props.onAction}
>
<CollectionPreviewBlock
key={slate.id}
collection={slate}
viewer={this.props.viewer}
/>
</Link>
))}
</div>
) : (
<EmptyState>
You can follow any public collections on the network.
<ButtonSecondary onClick={this._handleSearch} style={{ marginTop: 32 }}>
Browse collections
</ButtonSecondary>
</EmptyState>
)
) : null}
</div>
</ScenePage>
</WebsitePrototypeWrapper>
);

View File

@ -176,7 +176,7 @@ app.prepare().then(async () => {
// if (viewer) {
// return res.redirect("/_/data");
// } else {
// return res.redirect("/_/explore");
// return res.redirect("/_/activity");
// }
// let page = NavigationData.getById(null, viewer);