mirror of
https://github.com/filecoin-project/slate.git
synced 2024-12-28 19:42:01 +03:00
Merge pull request #835 from filecoin-project/@aminejv/activity
Feat: Activity Page
This commit is contained in:
commit
e0fa932523
@ -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`, {
|
||||
|
175
common/activity-utilities.js
Normal file
175
common/activity-utilities.js
Normal 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)
|
@ -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
2
common/environment.js
Normal file
@ -0,0 +1,2 @@
|
||||
//NOTE(amine): feature flags
|
||||
export const ACTIVITY_FEATURE_FLAG = !!process.env.NEXT_PUBLIC_ACTIVITY_FEATURE_FLAG;
|
207
common/hooks.js
207
common/hooks.js
@ -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 };
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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));
|
||||
}
|
||||
`;
|
||||
|
@ -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};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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());
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
132
components/core/ActivityGroup/components/ActivityFileGroup.js
Normal file
132
components/core/ActivityGroup/components/ActivityFileGroup.js
Normal 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>
|
||||
);
|
||||
}
|
110
components/core/ActivityGroup/components/ActivityProfileGroup.js
Normal file
110
components/core/ActivityGroup/components/ActivityProfileGroup.js
Normal 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>
|
||||
);
|
||||
}
|
118
components/core/ActivityGroup/components/ProfileInfo.js
Normal file
118
components/core/ActivityGroup/components/ProfileInfo.js
Normal 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]}
|
||||
>
|
||||
•
|
||||
</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>
|
||||
);
|
||||
}
|
68
components/core/ActivityGroup/components/ViewMoreContent.js
Normal file
68
components/core/ActivityGroup/components/ViewMoreContent.js
Normal 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>
|
||||
);
|
||||
}
|
5
components/core/ActivityGroup/components/index.js
Normal file
5
components/core/ActivityGroup/components/index.js
Normal 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";
|
74
components/core/ActivityGroup/index.js
Normal file
74
components/core/ActivityGroup/index.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
@ -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" }} />
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
`;
|
||||
|
@ -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%,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default as FollowButton } from "./FollowButton";
|
50
components/core/CollectionPreviewBlock/hooks.js
Normal file
50
components/core/CollectionPreviewBlock/hooks.js
Normal 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 };
|
||||
};
|
298
components/core/CollectionPreviewBlock/index.js
Normal file
298
components/core/CollectionPreviewBlock/index.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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 }} />
|
||||
|
39
components/core/ObjectPreview/FontObjectPreview.js
Normal file
39
components/core/ObjectPreview/FontObjectPreview.js
Normal 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>
|
||||
);
|
||||
}
|
98
components/core/ObjectPreview/ImageObjectPreview.js
Normal file
98
components/core/ObjectPreview/ImageObjectPreview.js
Normal 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>
|
||||
);
|
||||
}
|
41
components/core/ObjectPreview/LinkObjectPreview.js
Normal file
41
components/core/ObjectPreview/LinkObjectPreview.js
Normal 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>
|
||||
);
|
||||
}
|
160
components/core/ObjectPreview/ObjectPreviewPrimitive.js
Normal file
160
components/core/ObjectPreview/ObjectPreviewPrimitive.js
Normal 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>
|
||||
);
|
||||
}
|
61
components/core/ObjectPreview/TextObjectPreview.js
Normal file
61
components/core/ObjectPreview/TextObjectPreview.js
Normal 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>
|
||||
);
|
||||
}
|
110
components/core/ObjectPreview/components/LikeButton.jsx
Normal file
110
components/core/ObjectPreview/components/LikeButton.jsx
Normal 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 };
|
||||
};
|
113
components/core/ObjectPreview/components/SaveButton.jsx
Normal file
113
components/core/ObjectPreview/components/SaveButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
2
components/core/ObjectPreview/components/index.js
Normal file
2
components/core/ObjectPreview/components/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as LikeButton } from "./LikeButton";
|
||||
export { default as SaveButton } from "./SaveButton";
|
124
components/core/ObjectPreview/index.js
Normal file
124
components/core/ObjectPreview/index.js
Normal 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>
|
||||
);
|
||||
};
|
90
components/core/ObjectPreview/placeholders/3D.js
Normal file
90
components/core/ObjectPreview/placeholders/3D.js
Normal 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>
|
||||
);
|
||||
}
|
46
components/core/ObjectPreview/placeholders/Audio.js
Normal file
46
components/core/ObjectPreview/placeholders/Audio.js
Normal 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>
|
||||
);
|
||||
}
|
81
components/core/ObjectPreview/placeholders/Code.js
Normal file
81
components/core/ObjectPreview/placeholders/Code.js
Normal file
File diff suppressed because one or more lines are too long
104
components/core/ObjectPreview/placeholders/EPUB.js
Normal file
104
components/core/ObjectPreview/placeholders/EPUB.js
Normal file
File diff suppressed because one or more lines are too long
87
components/core/ObjectPreview/placeholders/File.js
Normal file
87
components/core/ObjectPreview/placeholders/File.js
Normal 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>
|
||||
);
|
||||
}
|
100
components/core/ObjectPreview/placeholders/Keynote.js
Normal file
100
components/core/ObjectPreview/placeholders/Keynote.js
Normal 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>
|
||||
);
|
||||
}
|
90
components/core/ObjectPreview/placeholders/PDF.js
Normal file
90
components/core/ObjectPreview/placeholders/PDF.js
Normal file
File diff suppressed because one or more lines are too long
53
components/core/ObjectPreview/placeholders/Video.js
Normal file
53
components/core/ObjectPreview/placeholders/Video.js
Normal 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>
|
||||
);
|
||||
}
|
105
components/core/ObjectPreview/placeholders/index.js
Normal file
105
components/core/ObjectPreview/placeholders/index.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
108
components/core/ProfilePreviewBlock.js
Normal file
108
components/core/ProfilePreviewBlock.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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 });
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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)}
|
||||
>
|
||||
|
@ -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}>
|
||||
|
@ -28,6 +28,7 @@ export default function ThemeProvider({ children }) {
|
||||
font: Constants.font,
|
||||
typescale: Constants.typescale,
|
||||
semantic: Constants.semantic,
|
||||
grids: Constants.grids,
|
||||
...theme,
|
||||
}),
|
||||
[theme]
|
||||
|
36
components/system/components/AspectRatio.js
Normal file
36
components/system/components/AspectRatio.js
Normal 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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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"];
|
||||
|
@ -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,
|
||||
|
@ -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");
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
|
23
node_common/data/methods/increment-file-savecount.js
Normal file
23
node_common/data/methods/increment-file-savecount.js
Normal 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",
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -14,7 +14,7 @@ export const getServerSideProps = async ({ query }) => {
|
||||
// },
|
||||
// };
|
||||
return {
|
||||
props: { ...JSON.parse(JSON.stringify(query)) },
|
||||
props: JSON.parse(JSON.stringify({ ...query })),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
117
scenes/SceneActivity/hooks.js
Normal file
117
scenes/SceneActivity/hooks.js
Normal 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 };
|
||||
}
|
158
scenes/SceneActivity/index.js
Normal file
158
scenes/SceneActivity/index.js
Normal 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;
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user