Merge pull request #835 from filecoin-project/@aminejv/activity

Feat: Activity Page
This commit is contained in:
martinalong 2021-08-02 19:03:14 -07:00 committed by GitHub
commit e0fa932523
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 4331 additions and 953 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

@ -16,7 +16,7 @@ export const sizes = {
export const system = {
//system color
white: "#FFFFFF",
grayLight6: "#F2F5F7",
grayLight6: "#F7F8F9",
grayLight5: "#E5E8EA",
grayLight4: "#D1D4D6",
grayLight3: "#C7CACC",
@ -96,9 +96,9 @@ export const semantic = {
bgBlurWhite: "rgba(255, 255, 255, 0.7)",
bgBlurWhiteOP: "rgba(255, 255, 255, 0.85)",
bgBlurWhiteTRN: "rgba(255, 255, 255, 0.3)",
bgBlurLight6: "rgba(242, 245, 247, 0.7)",
bgBlurLight6OP: "rgba(242, 245, 247, 0.85)",
bgBlurLight6TRN: "rgba(242, 245, 247, 0.3)",
bgBlurLight6: "rgba(247, 248, 249, 0.7)",
bgBlurLight6OP: "rgba(247, 248, 249, 0.85)",
bgBlurLight6TRN: "rgba(247, 248, 249, 0.3)",
bgDark: system.grayDark6,
bgLightDark: system.grayDark5,
@ -195,3 +195,27 @@ export const filetypes = {
};
export const linkPreviewSizeLimit = 5000000; //NOTE(martina): 5mb limit for twitter preview images
// NOTE(amine): used to calculate how many cards will fit into a row in sceneActivity
export const grids = {
activity: {
profileInfo: {
width: 260,
},
},
object: {
desktop: { width: 248, rowGap: 16 },
mobile: { width: 166, rowGap: 8 },
},
collection: {
desktop: { width: 432, rowGap: 16 },
mobile: { width: 300, rowGap: 8 },
},
profile: {
desktop: { width: 248, rowGap: 16 },
mobile: { width: 248, rowGap: 8 },
},
};
export const profileDefaultPicture =
"https://slate.textile.io/ipfs/bafkreick3nscgixwfpq736forz7kzxvvhuej6kszevpsgmcubyhsx2pf7i";

2
common/environment.js Normal file
View File

@ -0,0 +1,2 @@
//NOTE(amine): feature flags
export const ACTIVITY_FEATURE_FLAG = !!process.env.NEXT_PUBLIC_ACTIVITY_FEATURE_FLAG;

View File

@ -1,16 +1,17 @@
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);
export const useMounted = (callback, depedencies) => {
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
return isMounted.current;
if (mountedRef.current && callback) {
callback();
}
mountedRef.current = true;
}, depedencies);
};
/** NOTE(amine):
* useForm handles three main responsabilities
* - control inputs
@ -39,10 +40,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 +165,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 +181,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 +224,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

@ -1,5 +1,5 @@
import * as Actions from "~/common/actions";
import * as Strings from "~/common/strings";
import * as Environment from "~/common/environment";
export const getById = (id, viewer) => {
let target;
@ -12,7 +12,7 @@ export const getById = (id, viewer) => {
}
if (viewer && target.id === authPage.id) {
return { ...activityPage }; //NOTE(martina): authenticated users should be redirected to the home page rather than the
return { ...dataPage }; //NOTE(martina): authenticated users should be redirected to the home page rather than the
}
if (!viewer && !target.externalAllowed) {
@ -31,7 +31,7 @@ export const getByHref = (href, viewer) => {
return { page: { ...errorPage } };
}
if (pathname === "/_") {
return { page: { ...activityPage } };
return { page: { ...dataPage } };
}
let page = navigation.find((each) => pathname.startsWith(each.pathname));
@ -65,7 +65,7 @@ export const getByHref = (href, viewer) => {
if (viewer && page.id === authPage.id) {
redirected = true;
page = { ...activityPage };
page = { ...dataPage };
}
if (!viewer && !page.externalAllowed) {
@ -105,6 +105,15 @@ const dataPage = {
mainNav: true,
};
const collectionsPage = {
id: "NAV_SLATES",
name: "Collections",
pageTitle: "Your Collections",
ignore: true,
pathname: "/_/collections",
mainNav: true,
};
const activityPage = {
id: "NAV_ACTIVITY",
name: "Activity",
@ -112,7 +121,7 @@ const activityPage = {
ignore: true,
externalAllowed: true,
pathname: "/_/activity",
mainNav: true,
mainNav: Environment.ACTIVITY_FEATURE_FLAG,
};
const slatePage = {
@ -145,16 +154,9 @@ const errorPage = {
export const navigation = [
errorPage,
authPage,
activityPage,
...(Environment.ACTIVITY_FEATURE_FLAG ? [activityPage] : []),
collectionsPage,
dataPage,
{
id: "NAV_SLATES",
name: "Collections",
pageTitle: "Your Collections",
ignore: true,
pathname: "/_/collections",
mainNav: true,
},
// {
// id: "NAV_SEARCH",
// name: "Search",

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}
`;
@ -98,7 +111,7 @@ export const P3 = css`
font-family: ${Constants.font.text};
font-size: 0.75rem;
font-weight: normal;
line-height: 1.3;
line-height: 1.33;
letter-spacing: 0px;
${TEXT}
@ -220,3 +233,45 @@ export const IMAGE_FIT = css`
height: 100%;
object-fit: contain;
`;
/* COMMON GRIDS */
export const OBJECTS_PREVIEW_GRID = (theme) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.object.desktop.width}px, 1fr));
grid-gap: 24px ${theme.grids.object.desktop.rowGap}px;
@media (max-width: ${Constants.sizes.mobile}px) {
grid-gap: 20px ${theme.grids.object.mobile.rowGap}px;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.object.mobile.width}px, 1fr));
}
`;
export const BUTTON_RESET = css`
padding: 0;
margin: 0;
background-color: unset;
border: none;
${HOVERABLE}
`;
export const COLLECTIONS_PREVIEW_GRID = (theme) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.collection.desktop.width}px, 1fr));
grid-gap: 24px ${theme.grids.collection.desktop.rowGap}px;
@media (max-width: ${Constants.sizes.desktop}px) {
grid-gap: 20px ${theme.grids.collection.mobile.rowGap}px;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.collection.mobile.width}px, 1fr));
}
`;
export const PROFILE_PREVIEW_GRID = (theme) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.profile.desktop.width}px, 1fr));
grid-gap: 24px ${theme.grids.profile.desktop.rowGap}px;
@media (max-width: ${Constants.sizes.mobile}px) {
grid-gap: 20px ${theme.grids.profile.mobile.rowGap}px;
grid-template-columns: repeat(auto-fill, minmax(${theme.grids.profile.mobile.width}px, 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,71 @@ 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>
);
export const Box = (props) => (
<svg width={16} height={16} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M14 10.667V5.333a1.333 1.333 0 00-.667-1.153L8.667 1.513a1.333 1.333 0 00-1.334 0L2.667 4.18A1.333 1.333 0 002 5.333v5.334a1.334 1.334 0 00.667 1.153l4.666 2.667a1.334 1.334 0 001.334 0l4.666-2.667A1.333 1.333 0 0014 10.667z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.18 4.64L8 8.007l5.82-3.367M8 14.72V8"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -83,3 +83,53 @@ export const coerceToArray = (input) => {
return [input];
}
};
export const getFileExtension = (filename) => filename?.split(".").pop();
export const getTimeDifferenceFromNow = (date, format = {}) => {
const defaultFormats = {
seconds: (time) => time + "s",
minutes: (time) => time + "m",
hours: (time) => time + "h",
days: (time) => time + "d",
currentYear: (month, day) => `${month} ${day}`,
default: (month, day, year) => `${month} ${day}, ${year}`,
};
const formatDate = { ...defaultFormats, ...format };
const pastDate = new Date(date);
const now = new Date();
const differenceInSeconds = Math.floor((now - pastDate) / 1000);
if (differenceInSeconds < 60) {
return formatDate.seconds(differenceInSeconds);
}
const differenceInMinutes = Math.floor(differenceInSeconds / 60);
if (differenceInMinutes < 60) {
return formatDate.minutes(differenceInMinutes);
}
const differenceInHours = Math.floor(differenceInMinutes / 60);
if (differenceInHours < 24) {
return formatDate.hours(differenceInHours);
}
const differenceInDays = Math.floor(differenceInHours / 24);
if (differenceInDays < 24) {
return formatDate.days(differenceInDays);
}
const currentYear = now.getFullYear();
const day = pastDate.getDay();
const month = pastDate.toLocaleString("default", { month: "short" });
const year = pastDate.getFullYear();
if (year === currentYear) {
return formatDate.currentYear(month, day);
}
return formatDate.default(month, day, 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,122 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
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,
nbrOfCollectionsPerRow = 2,
...props
}) {
const { owner, slate, type, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(slate)) {
return { elements: [slate] };
}
return {
elements: slate.slice(0, nbrOfCollectionsPerRow),
restElements: slate.slice(nbrOfCollectionsPerRow),
};
}, [slate]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
// 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={createdAt}
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}
owner={collection.owner}
onAction={onAction}
/>
</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}
owner={collection.owner}
onAction={onAction}
/>
</Link>
</motion.div>
) : (
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
<CollectionPreviewBlock
collection={collection}
viewer={viewer}
owner={collection.owner}
onAction={onAction}
/>
</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,132 @@
import * as React from "react";
import * as Strings from "~/common/strings";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react";
import { motion } from "framer-motion";
import { ViewMoreContent, ProfileInfo } from "~/components/core/ActivityGroup/components";
import { Link } from "~/components/core/Link";
import ObjectPreview from "~/components/core/ObjectPreview";
const STYLES_GROUP_GRID = (theme) => css`
display: grid;
grid-template-columns: ${theme.grids.activity.profileInfo.width}px 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,
onFileClick,
onAction,
nbrOfObjectsPerRow = 4,
}) {
const { file: files, owner, slate, type, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(files)) {
return { elements: [files] };
}
return {
elements: files.slice(0, nbrOfObjectsPerRow),
restElements: files.slice(nbrOfObjectsPerRow),
};
}, [files]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
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={createdAt}
owner={owner}
action={action}
viewer={viewer}
onAction={onAction}
/>
<div>
<div css={Styles.OBJECTS_PREVIEW_GRID}>
{elements.map((file, i) => (
<>
<button
key={file.id}
style={{ width: "100%" }}
css={Styles.BUTTON_RESET}
onClick={() => onFileClick(i, files)}
>
<ObjectPreview viewer={viewer} owner={file.owner} key={file.id} file={file} />
</button>
</>
))}
{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}
>
<button
key={file.id}
style={{ width: "100%" }}
css={Styles.BUTTON_RESET}
onClick={() => onFileClick(i + nbrOfObjectsPerRow, files)}
>
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
</button>
</motion.div>
) : (
<button
key={file.id}
style={{ width: "100%" }}
css={Styles.BUTTON_RESET}
onClick={() => onFileClick(i + nbrOfObjectsPerRow, files)}
>
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
</button>
)
)}
</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,110 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
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,
nbrOfProfilesPerRow = 2,
onAction,
}) {
const { owner, user, createdAt } = group;
const { elements, restElements } = React.useMemo(() => {
if (!Array.isArray(user)) {
return { elements: [user] };
}
return {
elements: user.slice(0, nbrOfProfilesPerRow),
restElements: user.slice(nbrOfProfilesPerRow),
};
}, [user]);
const [showMore, setShowMore] = React.useState(false);
const viewMoreFiles = () => setShowMore(true);
return (
<div css={STYLES_GROUP_GRID}>
<ProfileInfo
time={createdAt}
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,118 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import * as Strings from "~/common/strings";
import * as Constants from "~/common/constants";
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 formattedDate = Utilities.getTimeDifferenceFromNow(time);
const mobileFormattedDate = Utilities.getTimeDifferenceFromNow(time, {
seconds: (time) => `${time} ${Strings.pluralize("second", time)} ago`,
minutes: (time) => `${time} ${Strings.pluralize("minute", time)} ago`,
hours: (time) => `${time} ${Strings.pluralize("hour", time)} ago`,
days: (time) => `${time} ${Strings.pluralize("day", time)} ago`,
});
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}
onError={(e) => (e.target.src = Constants.profileDefaultPicture)}
/>
<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" }}>
<span css={Styles.MOBILE_HIDDEN}>{formattedDate}</span>
<span css={Styles.MOBILE_ONLY}>{mobileFormattedDate}</span>
</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.9} 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,74 @@
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,
onFileClick,
external,
group,
nbrOfCardsPerRow,
}) {
const { type } = group;
if (
type === "CREATE_FILE" ||
type === "CREATE_SLATE_OBJECT" ||
type === "LIKE_FILE" ||
type === "SAVE_COPY"
) {
return (
<ActivityFileGroup
nbrOfObjectsPerRow={nbrOfCardsPerRow.object}
viewer={viewer}
onAction={onAction}
group={group}
onFileClick={onFileClick}
/>
);
}
if (type === "CREATE_SLATE" || type === "SUBSCRIBE_SLATE") {
return (
<ActivityCollectionGroup
onAction={onAction}
viewer={viewer}
group={group}
nbrOfCollectionsPerRow={nbrOfCardsPerRow.collection}
/>
);
}
if (type === "SUBSCRIBE_USER") {
return (
<ActivityProfileGroup
nbrOfProfilesPerRow={nbrOfCardsPerRow.profile}
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

@ -13,6 +13,7 @@ import * as Websockets from "~/common/browser-websockets";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Events from "~/common/custom-events";
import * as Logging from "~/common/logging";
import * as Environment from "~/common/environment";
// NOTE(jim):
// Scenes each have an ID and can be navigated to with _handleAction
@ -79,7 +80,7 @@ const SIDEBARS = {
const SCENES = {
NAV_ERROR: <SceneError />,
NAV_SIGN_IN: <SceneAuth />,
NAV_ACTIVITY: <SceneActivity />,
...(Environment.ACTIVITY_FEATURE_FLAG ? { NAV_ACTIVITY: <SceneActivity /> } : {}),
NAV_DIRECTORY: <SceneDirectory />,
NAV_PROFILE: <SceneProfile />,
NAV_DATA: <SceneFilesFolder />,
@ -88,7 +89,6 @@ const SCENES = {
NAV_API: <SceneSettingsDeveloper />,
NAV_SETTINGS: <SceneEditAccount />,
NAV_SLATES: <SceneSlates />,
NAV_DIRECTORY: <SceneDirectory />,
NAV_FILECOIN: <SceneArchive />,
NAV_STORAGE_DEAL: <SceneMakeFilecoinDeal />,
};
@ -433,7 +433,7 @@ export default class ApplicationPage extends React.Component {
// if (!redirected) {
// this._handleAction({ type: "NAVIGATE", value: "NAV_DATA" });
// }
this._handleNavigateTo({ href: "/_/activity", redirect: true });
this._handleNavigateTo({ href: "/_/data", redirect: true });
return response;
};

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);
@ -201,11 +202,7 @@ export default class ApplicationHeader extends React.Component {
<header css={STYLES_APPLICATION_HEADER_CONTAINER}>
<div css={STYLES_APPLICATION_HEADER}>
<div css={STYLES_LEFT}>
<Link
onAction={this.props.onAction}
href="/_/activity"
style={{ pointerEvents: "auto" }}
>
<Link onAction={this.props.onAction} href="/_/data" style={{ pointerEvents: "auto" }}>
<DarkSymbol style={{ height: 24, display: "block" }} />
</Link>
<div css={Styles.MOBILE_ONLY}>{searchComponent}</div>
@ -330,7 +327,7 @@ export default class ApplicationHeader extends React.Component {
<div css={STYLES_LEFT}>
<Link
onAction={this.props.onAction}
href="/_/activity"
href="/_/data"
style={{ pointerEvents: "auto" }}
>
<DarkSymbol style={{ height: 24, display: "block" }} />
@ -395,7 +392,7 @@ export default class ApplicationHeader extends React.Component {
<div css={STYLES_MIDDLE}>
<Link
onAction={this.props.onAction}
href="/_/activity"
href="/_/data"
style={{ pointerEvents: "auto" }}
>
<DarkSymbol style={{ height: 24, display: "block" }} />

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,112 @@
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";
import { P3 } from "~/components/system";
import { useMounted } from "~/common/hooks";
const STYLES_BUTTON = (theme) => css`
display: flex;
background-color: ${theme.semantic.bgBlurWhite};
padding: 8px;
border-radius: 8px;
box-shadow: 0 0 0 1px ${theme.system.grayLight5}, ${theme.shadow.lightLarge};
transition: box-shadow 0.3s;
:hover {
box-shadow: 0 0 0 1px ${theme.system.pink}, ${theme.shadow.lightLarge};
}
path {
transition: stroke 0.3s;
}
:hover path {
stroke: ${theme.system.pink};
}
`;
const STYLES_DISABLED = css`
cursor: not-allowed;
`;
const animate = async (controls) => {
await controls.start({ x: -2, y: 2 });
await controls.start({ x: 0, y: 0 });
};
export default function FollowButton({ onFollow, isFollowed, disabled, followCount, ...props }) {
const controls = useAnimation();
useMounted(() => {
if (isFollowed) {
animate(controls);
return;
}
}, [isFollowed]);
return (
<motion.button
css={[Styles.BUTTON_RESET, STYLES_BUTTON, disabled && STYLES_DISABLED]}
initial={{
backgroundColor: isFollowed ? Constants.system.redLight6 : Constants.semantic.bgBlurWhite,
}}
animate={{
backgroundColor: isFollowed ? Constants.system.redLight6 : Constants.semantic.bgBlurWhite,
}}
onClick={(e) => {
if (disabled) {
return;
}
e.preventDefault();
e.stopPropagation();
if (onFollow) onFollow();
}}
>
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<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"
initial={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
animate={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
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"
initial={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
animate={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
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"
initial={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
animate={{ stroke: isFollowed ? Constants.system.pink : Constants.system.black }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<P3
as={motion.p}
style={{ marginLeft: 4, y: -0.5 }}
initial={{ color: isFollowed ? Constants.system.pink : Constants.semantic.textGrayDark }}
animate={{ color: isFollowed ? Constants.system.pink : Constants.semantic.textGrayDark }}
>
{followCount}
</P3>
</span>
</motion.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,298 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Typography from "~/components/system/components/Typography";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import { Logo } from "~/common/logo";
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";
import { Link } from "~/components/core/Link";
import { AnimatePresence, motion } from "framer-motion";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_CONTAINER = (theme) => css`
position: relative;
display: flex;
flex-direction: column;
background-color: ${theme.semantic.bgLight};
box-shadow: 0 0 0 0.5px ${theme.semantic.bgGrayLight}, ${theme.shadow.lightSmall};
border-radius: 16px;
width: 100%;
overflow: hidden;
height: 304px;
@media (max-width: ${theme.sizes.mobile}px) {
height: 281px;
}
`;
const STYLES_PREVIEW = css`
flex-grow: 1;
background-size: cover;
overflow: hidden;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
`;
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
display: flex;
flex-direction: column;
position: relative;
padding: 9px 16px 12px;
border-radius: 0px 0px 16px 16px;
box-shadow: 0 -0.5px 0.5px ${theme.semantic.bgGrayLight};
width: 100%;
margin-top: auto;
`;
const STYLES_SPACE_BETWEEN = css`
justify-content: space-between;
`;
const STYLES_PROFILE_IMAGE = (theme) => css`
background-color: ${theme.semantic.bgLight};
height: 16px;
width: 16px;
border-radius: 4px;
object-fit: cover;
`;
const STYLES_METRICS = (theme) => css`
margin-top: 7px;
@media (max-width: ${theme.sizes.mobile}px) {
margin-top: 12px;
}
${Styles.CONTAINER_CENTERED};
${STYLES_SPACE_BETWEEN}
`;
const STYLES_PLACEHOLDER_CONTAINER = css`
height: 100%;
width: 100%;
`;
const STYLES_EMPTY_CONTAINER = css`
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
`;
const STYLES_CONTROLS = css`
position: absolute;
z-index: 1;
right: 16px;
top: 16px;
& > * + * {
margin-top: 8px !important;
}
`;
const STYLES_TEXT_GRAY = (theme) => css`
color: ${theme.semantic.textGray};
`;
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 getObjectToPreview = (objects = []) => {
let objectIdx = 0;
let isImage = false;
objects.some((object, i) => {
const isPreviewableImage = Validations.isPreviewableImage(object.data.type);
if (isPreviewableImage) (objectIdx = i), (isImage = true);
return isPreviewableImage;
});
return { ...objects[objectIdx], isImage };
};
const STYLES_DESCRIPTION_INNER = (theme) => css`
background-color: ${theme.semantic.bgLight};
border-radius: 16px;
`;
const Preview = ({ collection, children, ...props }) => {
const [isLoading, setLoading] = React.useState(true);
const handleOnLoaded = () => setLoading(false);
const previewerRef = React.useRef();
const { isInView } = useInView({
ref: previewerRef,
});
const isCollectionEmpty = collection.fileCount === 0;
if (isCollectionEmpty) {
return (
<div css={STYLES_EMPTY_CONTAINER} {...props}>
{children}
<Logo style={{ height: 18, marginBottom: 8 }} />
<Typography.P1 color="textGrayDark">No files in this collection</Typography.P1>
</div>
);
}
const object = getObjectToPreview(collection.objects);
if (object.isImage) {
const { coverImage } = object.data;
const blurhash = getFileBlurHash(object);
const previewImage = coverImage
? Strings.getURLfromCID(coverImage?.cid)
: Strings.getURLfromCID(object.cid);
return (
<div ref={previewerRef} css={STYLES_PREVIEW} {...props}>
{children}
{isInView && (
<>
{isLoading && blurhash && (
<Blurhash
hash={blurhash}
style={{ position: "absolute", top: 0, left: 0 }}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
)}
<img src={previewImage} alt="Collection preview" onLoad={handleOnLoaded} />
</>
)}
</div>
);
}
return (
<div css={STYLES_PREVIEW} {...props}>
{children}
<ObjectPlaceholder ratio={1} containerCss={STYLES_PLACEHOLDER_CONTAINER} file={object} />
</div>
);
};
export default function CollectionPreview({ collection, viewer, owner, onAction }) {
const [areControlsVisible, setShowControls] = React.useState(false);
const showControls = () => setShowControls(true);
const hideControls = () => setShowControls(false);
// const [isBodyVisible, setShowBody] = React.useState(false);
// const showBody = () => setShowBody(true);
// const hideBody = () => setShowBody(false);
// const body = collection?.data?.body;
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
const { fileCount } = collection;
return (
<div css={STYLES_CONTAINER}>
<Preview collection={collection} onMouseEnter={showControls} onMouseLeave={hideControls}>
<AnimatePresence>
{areControlsVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
css={STYLES_CONTROLS}
>
<FollowButton
onClick={follow}
isFollowed={isFollowed}
followCount={followCount}
disabled={collection.ownerId === viewer?.id}
/>
</motion.div>
)}
</AnimatePresence>
</Preview>
<div
css={STYLES_DESCRIPTION_CONTAINER}
// onMouseEnter={showBody} onMouseLeave={hideBody}
>
<div
css={STYLES_DESCRIPTION_INNER}
// initial={{ y: 0 }}
// animate={{ y: isBodyVisible ? -170 : 0 }}
// transition={{ type: "spring", stiffness: 170, damping: 26 }}
>
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
<Typography.H5 color="textBlack" nbrOflines={1}>
{collection.slatename}
</Typography.H5>
</div>
{/* {isBodyVisible && (
<div
style={{ marginTop: 4 }}
initial={{ opacity: 0 }}
animate={{ opacity: isBodyVisible ? 1 : 0 }}
>
<Typography.P2 color="textGrayDark" nbrOflines={5}>
{body || "sorry, no description available."}
</Typography.P2>
</div>
)} */}
</div>
<div css={STYLES_METRICS}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_TEXT_GRAY]}>
<SVG.Box />
<Typography.P3 style={{ marginLeft: 4 }} color="textGray">
{fileCount}
</Typography.P3>
</div>
{owner && (
<div style={{ alignItems: "end" }} css={Styles.CONTAINER_CENTERED}>
<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`}
onError={(e) => (e.target.src = Constants.profileDefaultPicture)}
/>
</Link>
<Link
href={`/$/user/${owner.id}`}
onAction={onAction}
aria-label={`Visit ${owner.username}'s profile`}
title={`Visit ${owner.username}'s profile`}
>
<Typography.P3 style={{ marginLeft: 8 }} color="textGray">
{owner.username}
</Typography.P3>
</Link>
</div>
)}
</div>
</div>
</div>
);
}

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

@ -96,7 +96,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,39 @@
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 ObjectPreviewPrimitive from "./ObjectPreviewPrimitive";
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`
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 (
<ObjectPreviewPrimitive 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>
</ObjectPreviewPrimitive>
);
}

View File

@ -0,0 +1,98 @@
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 ObjectPreviewPrimitive from "./ObjectPreviewPrimitive";
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;
height: 100%;
width: 100%;
`;
const ImagePlaceholder = ({ blurhash }) => (
<div css={STYLES_PLACEHOLDER_ABSOLUTE}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
<AspectRatio ratio={1}>
<div>
<Blurhash
hash={blurhash}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
</div>
</AspectRatio>
</div>
</div>
);
export default function ImageObjectPreview({
url,
file,
//NOTE(amine): ImageObjectPreview is used to display cover image for other objects, so we need to pass the tag down
tag,
...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 imgTag = 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
? coverImage?.data?.url || Strings.getURLfromCID(coverImage?.cid)
: url;
return (
<ObjectPreviewPrimitive file={file} tag={tag || imgTag} isImage {...props}>
<div ref={previewerRef} css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
{isInView && (
<img
css={STYLES_IMAGE}
src={imageUrl}
alt={`${file.name} preview`}
onLoad={handleOnLoaded}
/>
)}
{shouldShowPlaceholder && <ImagePlaceholder blurhash={blurhash} />}
</div>
</ObjectPreviewPrimitive>
);
}

View File

@ -0,0 +1,41 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import { H5, P3 } from "~/components/system/components/Typography";
import { css } from "@emotion/react";
import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive";
const STYLES_SOURCE_LOGO = css`
height: 14px;
width: 14px;
border-radius: 4px;
`;
export default function LinkObjectPreview({ file }) {
const {
data: { link },
} = file;
const tag = (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ transform: "translateY(3px)" }}>
{link.logo && (
<img
src={link.logo}
alt="Link source logo"
style={{ marginRight: 4 }}
css={STYLES_SOURCE_LOGO}
/>
)}
<P3 as="small" color="textGray">
{link.source}
</P3>
</div>
);
return (
<ObjectPreviewPrimitive file={file} tag={tag}>
<img src={link.image} alt="link preview" css={Styles.IMAGE_FILL} />
</ObjectPreviewPrimitive>
);
}

View File

@ -0,0 +1,160 @@
import * as React from "react";
import { css } from "@emotion/react";
import { H5, P3 } from "~/components/system/components/Typography";
import { AspectRatio } from "~/components/system";
// import { LikeButton, SaveButton } from "./components";
// import { useLikeHandler, useSaveHandler } from "~/common/hooks";
import { motion } from "framer-motion";
import ImageObjectPreview from "./ImageObjectPreview";
const STYLES_WRAPPER = (theme) => css`
position: relative;
background-color: ${theme.semantic.bgLight};
transition: box-shadow 0.2s;
box-shadow: 0 0 0 0.5px ${theme.semantic.bgGrayLight}, ${theme.shadow.lightSmall};
border-radius: 16px;
overflow: hidden;
cursor: pointer;
`;
const STYLES_DESCRIPTION = (theme) => css`
box-shadow: 0 -0.5px 0.5px ${theme.semantic.bgGrayLight};
border-radius: 0px 0px 16px 16px;
box-sizing: border-box;
width: 100%;
max-height: 61px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 8px;
}
`;
const STYLES_DESCRIPTION_INNER = (theme) => css`
background-color: ${theme.semantic.bgLight};
padding: 9px 16px 8px;
border-radius: 16px;
height: calc(170px + 61px);
`;
const STYLES_PREVIEW = css`
overflow: hidden;
position: relative;
`;
const STYLES_SELECTED_RING = (theme) => css`
box-shadow: 0 0 0 2px ${theme.system.blue};
`;
// const STYLES_CONTROLS = css`
// position: absolute;
// z-index: 1;
// right: 16px;
// top: 16px;
// & > * + * {
// margin-top: 8px !important;
// }
// `;
const STYLES_UPPERCASE = css`
text-transform: uppercase;
`;
export default function ObjectPreviewPrimitive({
children,
tag = "FILE",
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 showSaveButton = viewer?.id !== file?.ownerId;
// const [areControlsVisible, setShowControls] = React.useState(false);
// const showControls = () => setShowControls(true);
// const hideControls = () => setShowControls(false);
const [isBodyVisible, setShowBody] = React.useState(false);
const showBody = () => setShowBody(true);
const hideBody = () => setShowBody(false);
const body = file?.data?.body;
const title = file.data.name || file.filename;
if (file?.data?.coverImage && !isImage) {
return (
<ImageObjectPreview
file={file}
owner={owner}
tag={tag}
isSelected={isSelected}
onAction={onAction}
/>
);
}
return (
<div css={[STYLES_WRAPPER, isSelected && STYLES_SELECTED_RING]}>
<div
css={STYLES_PREVIEW}
// onMouseEnter={showControls} onMouseLeave={hideControls}
>
{/* <AnimatePresence>
{areControlsVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
css={STYLES_CONTROLS}
>
<LikeButton onClick={like} isLiked={isLiked} likeCount={likeCount} />
{showSaveButton && (
<SaveButton onSave={save} isSaved={isSaved} saveCount={saveCount} />
)}
</motion.div>
)}
</AnimatePresence> */}
<AspectRatio ratio={248 / 248}>
<div>{children}</div>
</AspectRatio>
</div>
<article css={STYLES_DESCRIPTION} onMouseEnter={showBody} onMouseLeave={hideBody}>
<motion.div
css={STYLES_DESCRIPTION_INNER}
initial={{ y: 0 }}
animate={{ y: isBodyVisible ? -170 : 0 }}
transition={{ type: "spring", stiffness: 170, damping: 26 }}
>
<H5 as="h2" nbrOflines={1} color="textBlack">
{title}
</H5>
<div style={{ marginTop: 3 }}>
{typeof tag === "string" ? (
<P3 as="small" css={STYLES_UPPERCASE} color="textGray">
{tag}
</P3>
) : (
tag
)}
</div>
<H5
as={motion.p}
initial={{ opacity: 0 }}
animate={{ opacity: isBodyVisible ? 1 : 0 }}
style={{ marginTop: 5 }}
nbrOflines={8}
color="textGrayDark"
>
{body || "sorry, no description available."}
</H5>
</motion.div>
</article>
</div>
);
}

View File

@ -0,0 +1,61 @@
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 FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
import ObjectPreviewPrimitive from "./ObjectPreviewPrimitive";
const STYLES_CONTAINER = css`
position: relative;
display: flex;
height: 100%;
justify-content: center;
`;
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 (
<ObjectPreviewPrimitive tag={!error && tag} file={file} {...props}>
<div css={[STYLES_CONTAINER, error && Styles.CONTAINER_CENTERED]}>
{error ? (
<>
<FilePlaceholder />
</>
) : (
<div css={STYLES_TEXT_PREVIEW}>
<P3>{content}</P3>
</div>
)}
</div>
</ObjectPreviewPrimitive>
);
}

View File

@ -0,0 +1,110 @@
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";
import { P3 } from "~/components/system";
import { useMounted } from "~/common/hooks";
const STYLES_BUTTON = (theme) => css`
display: flex;
background-color: ${theme.semantic.bgBlurWhite};
padding: 8px;
border-radius: 8px;
box-shadow: 0 0 0 1px ${theme.system.grayLight5}, ${theme.shadow.lightLarge};
transition: box-shadow 0.3s;
:hover {
box-shadow: 0 0 0 1px ${theme.system.pink}, ${theme.shadow.lightLarge};
}
svg {
transition: fill 0.1s;
}
:hover svg {
fill: ${theme.system.pink};
}
path {
transition: stroke 0.3s;
}
:hover path {
stroke: ${theme.system.pink};
}
`;
export default function LikeButton({ onClick, isLiked, likeCount }) {
const { heartAnimation, backgroundAnimation } = useAnimations({ isLiked });
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
if (onClick) onClick();
};
return (
<motion.button
css={[Styles.BUTTON_RESET, STYLES_BUTTON]}
initial={{
backgroundColor: isLiked ? Constants.system.redLight6 : Constants.semantic.bgBlurWhite,
}}
animate={backgroundAnimation}
onClick={handleClick}
>
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<motion.svg
width={16}
height={16}
initial={{ fill: isLiked ? Constants.system.pink : Constants.semantic.bgBlurWhite }}
animate={heartAnimation}
transition={{ duration: 0.3 }}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
d="M13.893 3.073a3.667 3.667 0 00-5.186 0L8 3.78l-.707-.707A3.667 3.667 0 102.107 8.26l.706.707L8 14.153l5.187-5.186.706-.707a3.667 3.667 0 000-5.187v0z"
stroke={Constants.semantic.textBlack}
initial={{ stroke: isLiked ? Constants.system.pink : Constants.semantic.textGrayDark }}
animate={{ stroke: isLiked ? Constants.system.pink : Constants.semantic.textGrayDark }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
<P3
as={motion.p}
style={{ marginLeft: 4, y: -0.5 }}
initial={{ color: isLiked ? Constants.system.pink : Constants.semantic.textGrayDark }}
animate={{ color: isLiked ? Constants.system.pink : Constants.semantic.textGrayDark }}
>
{likeCount}
</P3>
</span>
</motion.button>
);
}
const animateButton = async (heartAnimation, backgroundAnimation) => {
await heartAnimation.start({ scale: 1.3, rotateY: 180, fill: Constants.system.pink });
heartAnimation.start({ scale: 1, transition: { duration: 0.2 } });
backgroundAnimation.start({ backgroundColor: Constants.system.redLight6 });
heartAnimation.set({ rotateY: 0 });
};
const useAnimations = ({ isLiked }) => {
const backgroundAnimation = useAnimation();
const heartAnimation = useAnimation();
useMounted(() => {
if (isLiked) {
animateButton(heartAnimation, backgroundAnimation);
return;
}
// NOTE(amine): reset values to default
heartAnimation.start({ fill: Constants.semantic.bgBlurWhite, scale: 1 });
backgroundAnimation.start({ backgroundColor: Constants.semantic.bgBlurWhite });
}, [isLiked]);
return { heartAnimation, backgroundAnimation };
};

View File

@ -0,0 +1,113 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as Constants from "~/common/constants";
import { css } from "@emotion/react";
import { motion, useTransform, useMotionValue } from "framer-motion";
import { P3 } from "~/components/system";
const STYLES_BUTTON_HOVER = (theme) => css`
display: flex;
background-color: ${theme.semantic.bgBlurWhite};
padding: 8px;
border-radius: 8px;
box-shadow: 0 0 0 1px ${theme.system.grayLight5}, ${theme.shadow.lightLarge};
transition: box-shadow 0.3s;
:hover {
box-shadow: 0 0 0 1px ${theme.system.pink}, ${theme.shadow.lightLarge};
}
.button_path {
transition: stroke 0.3s;
}
:hover .button_path {
stroke: ${theme.system.pink};
}
`;
export default function SaveButton({ onSave, isSaved, saveCount, ...props }) {
const pathLength = useMotionValue(0);
const opacity = useTransform(pathLength, [0, 1], [0, 1]);
return (
<motion.button
css={[Styles.BUTTON_RESET, STYLES_BUTTON_HOVER]}
initial={{
backgroundColor: isSaved ? Constants.system.redLight6 : Constants.semantic.bgBlurWhite,
}}
animate={{
backgroundColor: isSaved ? Constants.system.redLight6 : Constants.semantic.bgBlurWhite,
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onSave) onSave();
}}
>
<span css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<motion.svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<motion.path
className="button_path"
d="M14.6667 12.6667C14.6667 13.0203 14.5262 13.3594 14.2761 13.6095C14.0261 13.8595 13.6869 14 13.3333 14H2.66665C2.31303 14 1.97389 13.8595 1.72384 13.6095C1.47379 13.3594 1.33332 13.0203 1.33332 12.6667V3.33333C1.33332 2.97971 1.47379 2.64057 1.72384 2.39052C1.97389 2.14048 2.31303 2 2.66665 2H5.99998L7.33332 4H13.3333C13.6869 4 14.0261 4.14048 14.2761 4.39052C14.5262 4.64057 14.6667 4.97971 14.6667 5.33333V12.6667Z"
stroke={Constants.system.black}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
initial={{
fill: isSaved ? Constants.system.pink : Constants.semantic.bgBlurWhite,
stroke: isSaved ? Constants.system.pink : Constants.system.black,
}}
animate={{
fill: isSaved ? Constants.system.pink : Constants.semantic.bgBlurWhite,
stroke: isSaved ? Constants.system.pink : Constants.system.black,
}}
/>
<motion.path
className="button_path"
d="M8 7.33332V11.3333"
animate={{ y: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
stroke="#00050A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path
className="button_path"
d="M6 9.33332H10"
stroke="#00050A"
animate={{ x: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/** NOTE(amine): checkmark path */}
<motion.path
initial={{ pathLength: isSaved ? 1 : pathLength, stroke: Constants.system.white }}
animate={{ pathLength: isSaved ? 1 : 0 }}
style={{ pathLength, opacity }}
d="M6 9.15385L6.92308 10.0769L10 7"
stroke="#00050A"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
<P3
as={motion.p}
style={{ marginLeft: 4, y: -0.5 }}
initial={{ color: isSaved ? Constants.system.pink : Constants.semantic.textGrayDark }}
animate={{ color: isSaved ? Constants.system.pink : Constants.semantic.textGrayDark }}
>
{saveCount}
</P3>
</span>
</motion.button>
);
}

View File

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

View File

@ -0,0 +1,124 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Strings from "~/common/strings";
import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities";
import { css } from "@emotion/react";
// Note(amine): placeholders
import PdfPlaceholder from "~/components/core/ObjectPreview/placeholders/PDF";
import VideoPlaceholder from "~/components/core/ObjectPreview/placeholders/Video";
import AudioPlaceholder from "~/components/core/ObjectPreview/placeholders/Audio";
import EbookPlaceholder from "~/components/core/ObjectPreview/placeholders/EPUB";
import KeynotePlaceholder from "~/components/core/ObjectPreview/placeholders/Keynote";
import CodePlaceholder from "~/components/core/ObjectPreview/placeholders/Code";
import Object3DPlaceholder from "~/components/core/ObjectPreview/placeholders/3D";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
// NOTE(amine): previews
import ImageObjectPreview from "~/components/core/ObjectPreview/ImageObjectPreview";
import TextObjectPreview from "~/components/core/ObjectPreview/TextObjectPreview";
import FontObjectPreview from "~/components/core/ObjectPreview/FontObjectPreview";
import LinkObjectPreview from "~/components/core/ObjectPreview/LinkObjectPreview";
import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive";
const ObjectPreview = ({ file, ...props }) => {
const { type, link } = file.data;
const url = Strings.getURLfromCID(file.cid);
if (link) {
return <LinkObjectPreview file={file} />;
}
if (Validations.isPreviewableImage(type)) {
return <ImageObjectPreview file={file} url={url} {...props} />;
}
if (type.startsWith("video/")) {
const tag = type.split("/")[1];
return (
<PlaceholderWrapper tag={tag} file={file} {...props}>
<VideoPlaceholder />
</PlaceholderWrapper>
);
}
if (Validations.isPdfType(type)) {
return (
<PlaceholderWrapper tag="PDF" file={file} {...props}>
<PdfPlaceholder />
</PlaceholderWrapper>
);
}
if (type.startsWith("audio/")) {
const tag = Utilities.getFileExtension(file.filename) || "audio";
return (
<PlaceholderWrapper tag={tag} file={file} {...props}>
<AudioPlaceholder />
</PlaceholderWrapper>
);
}
if (type === "application/epub+zip") {
return (
<PlaceholderWrapper tag="epub" file={file} {...props}>
<EbookPlaceholder />
</PlaceholderWrapper>
);
}
if (file.filename.endsWith(".key")) {
return (
<PlaceholderWrapper tag="keynote" file={file} {...props}>
<KeynotePlaceholder />
</PlaceholderWrapper>
);
}
if (Validations.isCodeFile(file.filename)) {
const tag = Utilities.getFileExtension(file.filename) || "code";
return (
<PlaceholderWrapper tag={tag} file={file} {...props}>
<CodePlaceholder />
</PlaceholderWrapper>
);
}
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 (
<PlaceholderWrapper tag="3d" file={file} {...props}>
<Object3DPlaceholder />
</PlaceholderWrapper>
);
}
return (
<PlaceholderWrapper tag="file" file={file} {...props}>
<FilePlaceholder />
</PlaceholderWrapper>
);
};
export default React.memo(ObjectPreview);
const STYLES_CONTAINER = css`
height: 100%;
`;
const PlaceholderWrapper = ({ children, ...props }) => {
return (
<ObjectPreviewPrimitive {...props}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>{children}</div>
</ObjectPreviewPrimitive>
);
};

View File

@ -0,0 +1,90 @@
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: ${(64 / 248) * 100 * ratio}%;
height: ${(71.25 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={64}
height={71.25}
viewBox="0 -5 64 71.25"
css={STYLES_PLACEHOLDER}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask id="prefix__a" maskUnits="userSpaceOnUse" x={1} y={3} width={64} height={65}>
<path
d="M65 48.136V22.531a5.919 5.919 0 00-.954-3.197 6.8 6.8 0 00-2.602-2.34L36.556 4.19A7.727 7.727 0 0033 3.333a7.727 7.727 0 00-3.556.858L4.556 16.994a6.8 6.8 0 00-2.601 2.34A5.918 5.918 0 001 22.53v25.605a5.919 5.919 0 00.955 3.197 6.801 6.801 0 002.6 2.34l24.89 12.803a7.728 7.728 0 003.555.857 7.728 7.728 0 003.556-.857l24.888-12.803a6.801 6.801 0 002.602-2.34A5.92 5.92 0 0065 48.136z"
fill="url(#prefix__paint0_linear)"
/>
</mask>
<g mask="url(#prefix__a)">
<path
d="M33 36.185l32-16.852.333 33.334L33 69.333V36.185z"
fill="url(#prefix__paint1_linear)"
/>
<path
d="M33.333 36.185l-32-16.852L1 52.667l32.333 16.666V36.185z"
fill="url(#prefix__paint2_linear)"
/>
<path d="M33 1.667l-33 17L33 36l33-17.333-33-17z" fill="url(#prefix__paint3_linear)" />
</g>
<defs>
<linearGradient
id="prefix__paint0_linear"
x1={33}
y1={3.333}
x2={33}
y2={67.333}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint1_linear"
x1={46.926}
y1={28.669}
x2={65.537}
y2={61.622}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint2_linear"
x1={19.407}
y1={28.669}
x2={0.796}
y2={61.622}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" />
<stop offset={1} stopColor="#C7C7CC" />
</linearGradient>
<linearGradient
id="prefix__paint3_linear"
x1={33.667}
y1={1.667}
x2={28.494}
y2={36.328}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#fff" stopOpacity={0.5} />
<stop offset={1} stopColor="#F7F8F9" />
</linearGradient>
</defs>
</svg>
);
}

View File

@ -0,0 +1,46 @@
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: ${(102 / 248) * 100 * ratio}%;
height: ${(102 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
viewBox="0 0 102 102"
width={102}
height={102}
css={STYLES_PLACEHOLDER}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle cx={51} cy={51} r={51} fill="url(#prefix__paint0_radial)" />
<path
d="M51 61c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10z"
fill="#F7F8F9"
/>
<path d="M49 47l6 4-6 4v-8z" fill="#C7CACC" />
<defs>
<radialGradient
id="prefix__paint0_radial"
cx={0}
cy={0}
r={1}
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(90 0 51) scale(54.7546)"
>
<stop stopColor="#C7CACC" />
<stop offset={1} stopColor="#F7F8F9" />
</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,87 @@
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: ${(64 / 248) * 100 * ratio}%;
height: ${(80 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={64}
height={80}
viewBox="63 52 64 80"
fill="none"
css={STYLES_PLACEHOLDER}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g filter="url(#prefix__filter0_d_code)">
<path
d="M72 132h48a8 8 0 008-8V78.627c0-4.243-1.686-8.313-4.686-11.313l-10.628-10.628c-3-3-7.07-4.686-11.313-4.686H72a8 8 0 00-8 8v64a8 8 0 008 8z"
fill="#fff"
/>
</g>
<g filter="url(#prefix__filter1_d_code)">
<path d="M120 69h5l-13-13v5a8 8 0 008 8z" fill="#D1D4D6" />
</g>
<path
d="M105 96v-8a2.001 2.001 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 0087 88v8a2 2 0 001 1.73l7 4a1.995 1.995 0 002 0l7-4a2.003 2.003 0 001-1.73z"
fill="#E5E8EA"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M87.27 86.96L96 92.01l8.73-5.05M96 102.08V92"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<filter
id="prefix__filter0_d_code"
x={0}
y={0}
width={192}
height={208}
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={32} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.690196 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_code"
x={100}
y={48}
width={37}
height={37}
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={4} />
<feGaussianBlur stdDeviation={6} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.69051 0 0 0 0 0.698039 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,100 @@
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: ${(96 / 248) * 100 * ratio}%;
height: ${(64 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={96}
height={64}
viewBox="65 50 96 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<g filter="url(#prefix__filter0_d_keynote)">
<rect x={68} y={56} width={96} height={60} rx={8} fill="#F7F8F9" />
</g>
<g filter="url(#prefix__filter1_d_keynote)">
<path
d="M72 112h80a8 8 0 008-8V78.627c0-4.243-1.686-8.313-4.686-11.313l-10.628-10.628c-3-3-7.07-4.686-11.313-4.686H72a8 8 0 00-8 8v44a8 8 0 008 8z"
fill="#fff"
/>
</g>
<g filter="url(#prefix__filter2_d_keynote)">
<path d="M152 69h5l-13-13v5a8 8 0 008 8z" fill="#D1D4D6" />
</g>
<path
d="M92.667 78h-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.333zM110.86 78.573L105.213 88a1.33 1.33 0 00-.003 1.327 1.327 1.327 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 0zM136 90.667a6.667 6.667 0 100-13.334 6.667 6.667 0 000 13.334z"
fill="#E5E8EA"
stroke="#E5E8EA"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<filter
id="prefix__filter0_d_keynote"
x={4}
y={4}
width={224}
height={188}
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={32} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.690196 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={224}
height={188}
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={32} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.690196 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={132}
y={48}
width={37}
height={37}
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={4} />
<feGaussianBlur stdDeviation={6} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.69051 0 0 0 0 0.698039 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

View File

@ -0,0 +1,53 @@
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: ${(96 / 248) * 100 * ratio}%;
height: ${(64 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
viewBox="64 52 96 64"
width={96}
height={64}
css={STYLES_PLACEHOLDER}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g filter="url(#prefix__filter0_d_video)">
<rect x={64} y={52} width={96} height={64} rx={8} fill="#fff" />
</g>
<path
d="M112 94c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10z"
fill="#F7F8F9"
/>
<path d="M110 80l6 4-6 4v-8z" fill="#C7CACC" />
<defs>
<filter
id="prefix__filter0_d_video"
x={0}
y={0}
width={224}
height={192}
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={32} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.690196 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,105 @@
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 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 PlaceholderPrimitive = ({ 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.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>
)}
<PlaceholderPrimitive 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,10 +21,10 @@ 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 ProfilePhoto from "~/components/core/ProfilePhoto";
import ProfilePhoto from "~/components/core/ProfilePhoto";
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
const STYLES_PROFILE_BACKGROUND = css`
background-color: ${Constants.system.white};
@ -249,10 +250,7 @@ function UserEntry({ user, button, onClick, message, checkStatus }) {
<div key={user.username} css={STYLES_USER_ENTRY}>
<div css={STYLES_USER} onClick={onClick}>
<div css={STYLES_DIRECTORY_PROFILE_IMAGE}>
<ProfilePhoto
user={user}
size={24}
/>
<ProfilePhoto user={user} size={24} />
{isOnline && <div css={STYLES_DIRECTORY_STATUS_INDICATOR} />}
</div>
<span css={STYLES_DIRECTORY_NAME}>
@ -267,6 +265,7 @@ function UserEntry({ user, button, onClick, message, checkStatus }) {
function FilesPage({
library,
user,
isOwner,
isMobile,
viewer,
@ -297,6 +296,7 @@ function FilesPage({
{library.length ? (
<DataView
key="scene-profile"
user={user}
onAction={onAction}
viewer={viewer}
isOwner={isOwner}
@ -347,7 +347,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={tab === "collections" ? user : collection.owner}
/>
</Link>
))}
</div>
) : (
<EmptyState>
{tab === "collections" || fetched ? (
@ -438,7 +449,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>
);
@ -601,10 +612,7 @@ export default class Profile extends React.Component {
<div css={STYLES_PROFILE_BACKGROUND}>
<div css={STYLES_PROFILE_INFO}>
<div css={STYLES_PROFILE_IMAGE}>
<ProfilePhoto
user={user}
size={120}
/>
<ProfilePhoto user={user} size={120} />
{showStatusIndicator && this.checkStatus({ id: user.id }) && (
<div css={STYLES_STATUS_INDICATOR} />
)}
@ -668,7 +676,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,108 @@
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 * as Constants from "~/common/constants";
import * as SVG from "~/common/svg";
import { ButtonPrimary, ButtonTertiary } from "~/components/system/components/Buttons";
import { css } from "@emotion/react";
import { useFollowProfileHandler } from "~/common/hooks";
const STYLES_CONTAINER = (theme) => css`
width: 100%;
position: relative;
background-color: ${theme.semantic.bgLight};
box-shadow: 0 0 0 0.5px ${theme.semantic.bgGrayLight}, ${theme.shadow.lightSmall};
border-radius: 16px;
padding: 24px 16px 16px;
${Styles.VERTICAL_CONTAINER_CENTERED}
`;
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;
}
`;
export default function ProfilePreviewBlock({ onAction, viewer, profile }) {
const { handleFollow, isFollowing } = useFollowProfileHandler({
onAction,
viewer,
user: profile,
});
const isOwner = viewer?.id === profile.id;
return (
<div css={STYLES_CONTAINER}>
<img
css={STYLES_PROFILE_PREVIEW}
src={profile.data.photo}
alt={`${profile.username}`}
onError={(e) => (e.target.src = Constants.profileDefaultPicture)}
/>
<div>
<Typography.H5 style={{ marginTop: 17 }}>{profile.username}</Typography.H5>
</div>
<div
css={Styles.HORIZONTAL_CONTAINER}
style={{ marginTop: 6, color: Constants.semantic.textGray }}
>
<div css={Styles.HORIZONTAL_CONTAINER}>
<SVG.Box />
<Typography.P3 color="textGray" style={{ marginLeft: 4 }}>
{profile.fileCount} {Strings.pluralize("file", profile.fileCount)}
</Typography.P3>
</div>
<div css={Styles.HORIZONTAL_CONTAINER} style={{ marginLeft: 16 }}>
<SVG.Layers height={16} width={16} />
<Typography.P3 color="textGray" style={{ marginLeft: 4 }}>
{profile.slateCount} {Strings.pluralize("collection", profile.slateCount)}
</Typography.P3>
</div>
</div>
<Typography.P2
color="gray"
nbrOflines={1}
style={{ marginTop: 8, textIndent: 8, opacity: profile?.data?.body ? 1 : 0 }}
>
{profile?.data?.body || "No Description"}
</Typography.P2>
{!isOwner &&
(isFollowing ? (
<ButtonTertiary
style={{ marginTop: 16 }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(profile.id);
}}
full
>
Following
</ButtonTertiary>
) : (
<ButtonPrimary
style={{ marginTop: 16 }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFollow(profile.id);
}}
full
>
Follow
</ButtonPrimary>
))}
</div>
);
}

View File

@ -147,7 +147,7 @@ export class SignIn extends React.Component {
});
}
if (response && !response.error) {
window.location.replace("/_/activity");
window.location.replace("/_/data");
}
this.setState({ loading: false });
};

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

@ -28,6 +28,7 @@ export default function ThemeProvider({ children }) {
font: Constants.font,
typescale: Constants.typescale,
semantic: Constants.semantic,
grids: Constants.grids,
...theme,
}),
[theme]

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

@ -3,7 +3,61 @@ import * as Constants from "~/common/constants";
import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import { css } from "@emotion/react";
import { css, jsx } 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
@ -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,135 @@ export const A = ({ href, children, dark }) => {
// ${ANCHOR}
// `;
export const H1 = (props) => {
return <h1 {...props} css={[Styles.H1, props?.css]} />;
export const H1 = ({ as = "h1", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H1, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const H2 = (props) => {
return <h2 {...props} css={[Styles.H2, props?.css]} />;
export const H2 = ({ as = "h2", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H2, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const H3 = (props) => {
return <h3 {...props} css={[Styles.H3, props?.css]} />;
export const H3 = ({ as = "h3", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H3, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const H4 = (props) => {
return <h4 {...props} css={[Styles.H4, props?.css]} />;
export const H4 = ({ as = "h4", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H4, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const H5 = (props) => {
return <h5 {...props} css={[Styles.H5, props?.css]} />;
export const H5 = ({ as = "h5", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.H5, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
// 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;
export const P1 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
// strong,
// b {
// font-family: ${Constants.font.semiBold};
// font-weight: 400;
// }
// ${ANCHOR}
// `;
export const P1 = (props) => {
return <p {...props} css={[Styles.P1, props?.css]} />;
return jsx(
as,
{ ...props, css: [Styles.P1, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const P2 = (props) => {
return <p {...props} css={[Styles.P2, props?.css]} />;
export const P2 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.P2, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const P3 = (props) => {
return <p {...props} css={[Styles.P3, props?.css]} />;
export const P3 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.P3, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const C1 = (props) => {
return <p {...props} css={[Styles.C1, props?.css]} />;
export const C1 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.C1, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const C2 = (props) => {
return <p {...props} css={[Styles.C2, props?.css]} />;
export const C2 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.C2, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const C3 = (props) => {
return <p {...props} css={[Styles.C3, props?.css]} />;
export const C3 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.C3, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
export const B1 = (props) => {
return <p {...props} css={[Styles.B1, props?.css]} />;
export const B1 = ({ as = "p", nbrOflines, children, color, ...props }) => {
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
const COLOR_STYLES = useColorProp(color);
return jsx(
as,
{ ...props, css: [Styles.B1, TRUNCATE_STYLE, COLOR_STYLES, props?.css] },
children
);
};
const STYLES_UL = css`
@ -151,29 +273,25 @@ const STYLES_UL = css`
padding-left: 24px;
`;
export const UL = (props) => {
return <ul css={STYLES_UL} {...props} />;
};
export const UL = ({ as = "ul", children, props }) =>
jsx(as, { ...props, css: [STYLES_UL, props?.css] }, children);
const STYLES_OL = css`
box-sizing: border-box;
padding-left: 24px;
`;
export const OL = (props) => {
return <ol css={STYLES_OL} {...props} />;
};
export const OL = ({ as = "ol", children, props }) =>
jsx(as, { ...props, css: [STYLES_OL, props?.css] }, children);
const STYLES_LI = css`
box-sizing: border-box;
margin-top: 12px;
strong {
font-family: ${Constants.font.semiBold};
font-weight: 400;
}
`;
export const LI = (props) => {
return <li css={STYLES_LI} {...props} />;
};
export const LI = ({ as = "li", children, props }) =>
jsx(as, { ...props, css: [STYLES_LI, props?.css] }, children);

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,167 @@ 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 slateOwnerFields = [
"slate_table",
"slate_with_objects.*",
...Constants.userPreviewProperties,
"owner",
"slate_with_objects",
"users",
"slate_with_objects.ownerId",
"users.id",
];
const slateOwnerQuery = `?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? ) `;
const slateFields = [
"slate_with_objects",
"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",
...slateOwnerFields,
];
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??), ${slateOwnerQuery}`;
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,147 @@
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 slateOwnerFields = [
"slate_table",
"slate_with_objects.*",
...Constants.userPreviewProperties,
"owner",
"slate_with_objects",
"users",
"slate_with_objects.ownerId",
"users.id",
];
const slateOwnerQuery = `?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? ) `;
const slateFields = [
"slate_with_objects",
"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",
...slateOwnerFields,
];
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??), ${slateOwnerQuery}`;
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

@ -11,6 +11,12 @@ export default async ({ ownerId }) => {
// const slateFiles = () =>
// DB.raw("json_agg(?? order by ?? asc) as ??", ["files", "slate_files.createdAt", "objects"]);
const ownerQueryFields = ["*", ...Constants.userPreviewProperties, "owner"];
const ownerQuery = DB.raw(
`??, json_build_object('id', ??, 'data', ??, 'username', ??) as ??`,
ownerQueryFields
);
const slateFiles = () =>
DB.raw("coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??", [
"files",
@ -19,14 +25,20 @@ export default async ({ ownerId }) => {
"objects",
]);
const query = await DB.select(...Serializers.slateProperties, slateFiles())
const query = await DB.with("slates", (db) =>
db
.select(...Serializers.slateProperties, slateFiles())
.from("slates")
.join("subscriptions", "subscriptions.slateId", "=", "slates.id")
.join("slate_files", "slate_files.slateId", "=", "slates.id")
.join("files", "slate_files.fileId", "=", "files.id")
.where({ "subscriptions.ownerId": ownerId, "slates.isPublic": true })
// .orderBy("subscriptions.createdAt", "desc");
.groupBy("slates.id")
)
.select(ownerQuery)
.from("slates")
.join("subscriptions", "subscriptions.slateId", "=", "slates.id")
.join("slate_files", "slate_files.slateId", "=", "slates.id")
.join("files", "slate_files.fileId", "=", "files.id")
.where({ "subscriptions.ownerId": ownerId, "slates.isPublic": true })
// .orderBy("subscriptions.createdAt", "desc");
.groupBy("slates.id");
.join("users", "slates.ownerId", "users.id");
if (!query || query.error) {
return [];
@ -39,7 +51,7 @@ export default async ({ ownerId }) => {
return JSON.parse(JSON.stringify(serialized));
},
errorFn: async (e) => {
errorFn: async () => {
Logging.error({
error: true,
decorator: "GET_SUBSCRIPTIONS_BY_USER_ID",

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;
@ -231,6 +241,8 @@ export const getById = async ({ id }) => {
subscriptions,
following,
followers,
likes,
libraryCids,
};
return viewer;

View File

@ -31,6 +31,7 @@ export const sanitizeSlate = (entity) => {
ownerId: entity.ownerId,
isPublic: entity.isPublic,
objects: entity.objects,
owner: entity.owner,
user: entity.user, //NOTE(martina): this is not in the database. It is added after
data: {
name: entity.data?.name,

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

@ -1,519 +0,0 @@
import * as React from "react";
import * as Constants from "~/common/constants";
import * as Validations from "~/common/validations";
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 { GlobalCarousel } from "~/components/system/components/GlobalCarousel";
import { css } from "@emotion/react";
import { TabGroup, PrimaryTabGroup, 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 WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
const STYLES_LOADER = css`
display: flex;
align-items: center;
justify-content: center;
height: calc(100vh - 400px);
width: 100%;
`;
const STYLES_IMAGE_BOX = css`
cursor: pointer;
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;
}
`;
const STYLES_TEXT_AREA = css`
position: absolute;
top: 16px;
left: 0px;
`;
const STYLES_TITLE = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${Constants.system.white};
font-family: ${Constants.font.medium};
margin-bottom: 4px;
width: calc(100% - 32px);
padding: 0px 16px;
box-sizing: content-box;
`;
const STYLES_SECONDARY = css`
${STYLES_TITLE}
font-size: ${Constants.typescale.lvlN1};
width: 100%;
`;
const STYLES_GRADIENT = css`
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.2) 26.56%,
rgba(0, 0, 0, 0) 100%
);
backdrop-filter: blur(2px);
width: 100%;
height: 72px;
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;
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>
);
}
}
// {this.state.showText || this.props.isMobile ? <div css={STYLES_GRADIENT} /> : null}
// {this.state.showText || this.props.isMobile ? (
// <div css={STYLES_TEXT_AREA} style={{ width: this.props.size }}>
// <span
// style={{
// color: Constants.system.white,
// padding: "8px 16px",
// }}
// css={STYLES_SECONDARY}
// >
// <SVG.ArrowDownLeft
// height="10px"
// style={{ transform: "scaleX(-1)", marginRight: 4 }}
// />
// {item.slate.data.name || item.slate.slatename}
// </span>
// </div>
// ) : null}
const ActivityRectangle = ({ item, width, height }) => {
let file;
for (let obj of item.slate?.objects || []) {
if (Validations.isPreviewableImage(obj.type) || obj.coverImage) {
file = obj;
}
}
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}
<div css={STYLES_GRADIENT} />
<div css={STYLES_TEXT_AREA}>
<div
css={STYLES_TITLE}
style={{
fontFamily: Constants.font.semiBold,
width,
}}
>
{item.slate.data.name || item.slate.slatename}
</div>
<div
css={STYLES_SECONDARY}
style={{
color: Constants.semantic.textGrayLight,
width,
}}
>
{numObjects} File{numObjects == 1 ? "" : "s"}
</div>
</div>
</div>
);
};
export default class SceneActivity extends React.Component {
counter = 0;
state = {
imageSize: 200,
loading: false,
carouselIndex: -1,
};
async componentDidMount() {
this.fetchActivityItems(true);
this.calculateWidth();
this.debounceInstance = Window.debounce(this.calculateWidth, 200);
this.scrollDebounceInstance = Window.debounce(this._handleScroll, 200);
window.addEventListener("resize", this.debounceInstance);
window.addEventListener("scroll", this.scrollDebounceInstance);
}
componentDidUpdate(prevProps) {
if (prevProps.page.params?.tab !== this.props.page.params?.tab) {
this.fetchActivityItems(true);
}
}
componentWillUnmount() {
window.removeEventListener("resize", this.debounceInstance);
window.removeEventListener("scroll", this.scrollDebounceInstance);
}
getTab = () => {
if (this.props.page.params?.tab) {
return this.props.page.params?.tab;
}
if (this.props.viewer?.subscriptions?.length || this.props.viewer?.following?.length) {
return "activity";
}
return "explore";
};
_handleScroll = (e) => {
if (this.state.loading) {
return;
}
const windowHeight =
"innerHeight" in window ? window.innerHeight : document.documentElement.offsetHeight;
const body = document.body;
const html = document.documentElement;
const docHeight = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
const windowBottom = windowHeight + window.pageYOffset;
if (windowBottom >= docHeight - 600) {
this.fetchActivityItems();
}
};
fetchActivityItems = async (update = false) => {
if (this.state.loading === "loading") return;
let tab = this.getTab();
const isExplore = tab === "explore";
this.setState({ loading: "loading" });
let activity;
if (this.props.viewer) {
activity = isExplore ? this.props.viewer?.explore || [] : this.props.viewer?.activity || [];
} else {
activity = this.state.explore || [];
}
let requestObject = {};
if (activity.length) {
if (update) {
requestObject.latestTimestamp = activity[0].createdAt;
} else {
requestObject.earliestTimestamp = activity[activity.length - 1].createdAt;
}
}
let response;
if (isExplore) {
response = await Actions.getExplore(requestObject);
} else {
requestObject.following = this.props.viewer.following.map((item) => item.id);
requestObject.subscriptions = this.props.viewer.subscriptions.map((item) => item.id);
response = await Actions.getActivity(requestObject);
}
if (Events.hasError(response)) {
this.setState({ loading: false });
return;
}
let newItems = response.data || [];
if (update) {
activity.unshift(...newItems);
this.counter = 0;
activity = this.formatActivity(activity);
} else {
newItems = this.formatActivity(newItems);
activity.push(...newItems);
}
if (this.props.viewer) {
if (!isExplore) {
this.props.onAction({ type: "UPDATE_VIEWER", viewer: { activity: activity } });
} else {
this.props.onAction({ type: "UPDATE_VIEWER", viewer: { explore: activity } });
}
this.setState({ loading: false });
} else {
this.setState({ explore: activity, loading: false });
}
};
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;
} else {
imageSize = (windowWidth - 2 * 56 - 5 * 20) / 6;
}
this.setState({ imageSize });
};
getItemIndexById = (items, item) => {
const id = item.file?.id;
return items.findIndex((i) => i.id === id);
};
render() {
let tab = this.getTab();
let activity;
if (this.props.viewer) {
activity =
tab === "activity" ? this.props.viewer?.activity || [] : this.props.viewer?.explore || [];
} else {
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>
{this.props.viewer && (
<SecondaryTabGroup
tabs={[
{ title: "My network", value: { tab: "activity" } },
{ title: "Explore", value: { tab: "explore" } },
]}
value={tab}
onAction={this.props.onAction}
style={{ marginTop: 0 }}
/>
)}
<GlobalCarousel
carouselType="ACTIVITY"
viewer={this.props.viewer}
objects={items}
onAction={(props) => {}}
index={this.state.index}
onChange={(index) => {
this.setState({ index });
if (index >= items.length - 4) {
this.fetchActivityItems();
}
}}
isMobile={this.props.isMobile}
params={this.props.page.params}
isOwner={false}
/>
{activity.length ? (
<div>
<div css={STYLES_ACTIVITY_GRID}>
{activity.map((item, i) => {
if (item.type === "CREATE_SLATE") {
return (
<Link
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({ index: i })}
>
{/* <span
key={item.id}
onClick={() =>
this.props.onAction({
type: "NAVIGATE",
value: "NAV_SLATE",
data: item.slate,
})
}
> */}
<ActivityRectangle
width={
this.props.isMobile
? this.state.imageSize
: this.state.imageSize * 2 + 20
}
height={this.state.imageSize}
item={item}
/>
{/* </span> */}
</Link>
);
} else if (item.type === "CREATE_SLATE_OBJECT") {
return (
<Link
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({ index: i })}
// onClick={
// this.props.isMobile
// ? () => {}
// : () =>
// Events.dispatchCustomEvent({
// name: "slate-global-open-carousel",
// detail: { index: this.getItemIndexById(items, item) },
// })
// }
>
<ActivitySquare
size={this.state.imageSize}
item={item}
isMobile={this.props.isMobile}
onAction={this.props.onAction}
/>
</Link>
);
} else {
return null;
}
})}
</div>
<div css={STYLES_LOADER} style={{ height: 100 }}>
{this.state.loading === "loading" ? (
<LoaderSpinner style={{ height: 32, width: 32 }} />
) : null}
</div>
</div>
) : this.state.loading === "loading" ? (
<div css={STYLES_LOADER}>
<LoaderSpinner style={{ height: 32, width: 32 }} />
</div>
) : (
<EmptyState>
<SVG.Users height="24px" />
<div style={{ marginTop: 24 }}>
Start following people and collections to see their activity here
</div>
</EmptyState>
)}
</ScenePage>
</WebsitePrototypeWrapper>
);
}
}

View File

@ -0,0 +1,117 @@
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).sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
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).sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
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 tab = "explore";
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,158 @@
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 { GlobalCarousel } from "~/components/system/components/GlobalCarousel";
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, ...props }) {
const { feed, tab, isLoading, updateFeed } = useActivity({
page,
viewer,
onAction,
});
const divRef = React.useRef();
const nbrOfCardsInRow = useNbrOfCardsPerRow(divRef);
const [globalCarouselState, setGlobalCarouselState] = React.useState({
currentCarrousel: -1,
currentObjects: [],
});
const handleFileClick = (fileIdx, groupFiles) =>
setGlobalCarouselState({ currentCarrousel: fileIdx, currentObjects: groupFiles });
console.log(globalCarouselState.currentCarrousel, globalCarouselState.currentObjects.length);
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
nbrOfCardsPerRow={nbrOfCardsInRow}
key={group.id}
viewer={viewer}
external={external}
onAction={onAction}
group={group}
onFileClick={handleFileClick}
/>
))}
</div>
<div ref={divRef} css={feed?.length ? STYLES_LOADING_CONTAINER : STYLES_LOADER}>
{isLoading[tab] && <LoaderSpinner style={{ height: 32, width: 32 }} />}
</div>
</ScenePage>
<GlobalCarousel
carouselType="ACTIVITY"
viewer={viewer}
objects={globalCarouselState.currentObjects}
index={globalCarouselState.currentCarrousel}
isMobile={props.isMobile}
onChange={(idx) => setGlobalCarouselState((prev) => ({ ...prev, currentCarrousel: idx }))}
isOwner={false}
onAction={() => {}}
/>
</WebsitePrototypeWrapper>
);
}
let NbrOfCardsInRow = {};
function useNbrOfCardsPerRow(ref) {
const calculateNbrOfCards = (card) => {
const isMobile = window.matchMedia(`(max-width: ${Constants.sizes.mobile}px)`).matches;
const profileInfoWidth = isMobile ? 0 : Constants.grids.activity.profileInfo.width;
const containerWidth = ref.current.offsetWidth - profileInfoWidth;
const nbrOfCardsWithoutGap = Math.floor(containerWidth / card.width);
const gapsWidth = (nbrOfCardsWithoutGap - 1) * card.gap;
return Math.floor((containerWidth - gapsWidth) / card.width) || 1;
};
React.useEffect(() => {
if (JSON.stringify(NbrOfCardsInRow) !== "{}") return;
const isMobile = window.matchMedia(`(max-width: ${Constants.sizes.mobile}px)`).matches;
const responsiveKey = isMobile ? "mobile" : "desktop";
const { width: objectPreviewWidth, rowGap: objectPreviewGridRowGap } =
Constants.grids.object[responsiveKey];
NbrOfCardsInRow.object = calculateNbrOfCards({
width: objectPreviewWidth,
gap: objectPreviewGridRowGap,
});
const { width: collectionPreviewWidth, rowGap: collectionPreviewGridRowGap } =
Constants.grids.collection[responsiveKey];
NbrOfCardsInRow.collection = calculateNbrOfCards({
width: collectionPreviewWidth,
gap: collectionPreviewGridRowGap,
});
const { width: profilePreviewWidth, rowGap: profilePreviewGridRowGap } =
Constants.grids.profile[responsiveKey];
NbrOfCardsInRow.profile = calculateNbrOfCards({
width: profilePreviewWidth,
gap: profilePreviewGridRowGap,
});
}, []);
return NbrOfCardsInRow;
}

View File

@ -469,6 +469,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,82 @@ 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}
owner={this.props.viewer}
onAction={this.props.onAction}
/>
</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}
owner={slate.owner}
viewer={this.props.viewer}
onAction={this.props.onAction}
/>
</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

@ -154,7 +154,7 @@ app.prepare().then(async () => {
});
server.get("/_", async (req, res) => {
return res.redirect("/_/activity");
return res.redirect("/_/data");
// let isMobile = Window.isMobileBrowser(req.headers["user-agent"]);
// let isMac = Window.isMac(req.headers["user-agent"]);
@ -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);