mirror of
https://github.com/filecoin-project/slate.git
synced 2024-11-23 05:54:49 +03:00
added: new colors tokens
This commit is contained in:
parent
c180d43e47
commit
3f863a3d0c
@ -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)
|
192
common/hooks.js
192
common/hooks.js
@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as Logging from "~/common/logging";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as Events from "~/common/custom-events";
|
||||
|
||||
export const useMounted = () => {
|
||||
const isMounted = React.useRef(true);
|
||||
@ -39,10 +41,12 @@ export const useForm = ({
|
||||
});
|
||||
|
||||
const _hasError = (obj) => Object.keys(obj).some((name) => obj[name]);
|
||||
const _mergeEventHandlers = (events = []) => (e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
const _mergeEventHandlers =
|
||||
(events = []) =>
|
||||
(e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
|
||||
/** ---------- NOTE(amine): Input Handlers ---------- */
|
||||
const handleFieldChange = (e) =>
|
||||
@ -162,10 +166,12 @@ export const useField = ({
|
||||
touched: undefined,
|
||||
});
|
||||
|
||||
const _mergeEventHandlers = (events = []) => (e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
const _mergeEventHandlers =
|
||||
(events = []) =>
|
||||
(e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
|
||||
/** ---------- NOTE(amine): Input Handlers ---------- */
|
||||
const handleFieldChange = (e) =>
|
||||
@ -176,14 +182,14 @@ export const useField = ({
|
||||
touched: false,
|
||||
}));
|
||||
|
||||
const handleOnBlur = (e) => {
|
||||
const handleOnBlur = () => {
|
||||
// NOTE(amine): validate the inputs onBlur and touch the current input
|
||||
let error = {};
|
||||
if (validateOnBlur && validate) error = validate(state.value);
|
||||
setState((prev) => ({ ...prev, touched: validateOnBlur, error }));
|
||||
};
|
||||
|
||||
const handleFormOnSubmit = (e) => {
|
||||
const handleFormOnSubmit = () => {
|
||||
//NOTE(amine): touch all inputs
|
||||
setState((prev) => ({ ...prev, touched: true }));
|
||||
|
||||
@ -219,3 +225,169 @@ export const useField = ({
|
||||
|
||||
return { getFieldProps, value: state.value, isSubmitting: state.isSubmitting };
|
||||
};
|
||||
|
||||
export const useIntersection = ({ onIntersect, ref }, dependencies = []) => {
|
||||
// NOTE(amine): fix for stale closure caused by hooks
|
||||
const onIntersectRef = React.useRef();
|
||||
onIntersectRef.current = onIntersect;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const lazyObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (onIntersectRef.current) onIntersectRef.current(lazyObserver, ref);
|
||||
}
|
||||
});
|
||||
});
|
||||
// start to observe element
|
||||
lazyObserver.observe(ref.current);
|
||||
return () => lazyObserver.unobserve(ref.current);
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
// NOTE(amine): the intersection will be called one time
|
||||
export const useInView = ({ ref }) => {
|
||||
const [isInView, setInView] = React.useState(false);
|
||||
useIntersection({
|
||||
ref,
|
||||
onIntersect: (lazyObserver, ref) => {
|
||||
setInView(true);
|
||||
lazyObserver.unobserve(ref.current);
|
||||
},
|
||||
});
|
||||
return { isInView };
|
||||
};
|
||||
|
||||
// NOTE(amine): manage like state
|
||||
export const useLikeHandler = ({ file, viewer }) => {
|
||||
const likedFile = React.useMemo(() => viewer?.likes?.find((item) => item.id === file.id), []);
|
||||
const [state, setState] = React.useState({
|
||||
isLiked: !!likedFile,
|
||||
// NOTE(amine): viewer will have the hydrated state
|
||||
likeCount: likedFile?.likeCount ?? file.likeCount,
|
||||
});
|
||||
|
||||
const handleLikeState = () => {
|
||||
setState((prev) => {
|
||||
if (prev.isLiked) {
|
||||
return {
|
||||
isLiked: false,
|
||||
likeCount: prev.likeCount - 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isLiked: true,
|
||||
likeCount: prev.likeCount + 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
const like = async () => {
|
||||
if (!viewer) {
|
||||
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
|
||||
return;
|
||||
}
|
||||
// NOTE(amine): optimistic update
|
||||
handleLikeState();
|
||||
const response = await Actions.like({ id: file.id });
|
||||
if (Events.hasError(response)) {
|
||||
// NOTE(amine): revert back to old state if there is an error
|
||||
handleLikeState();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return { like, ...state };
|
||||
};
|
||||
|
||||
// NOTE(amine): manage file saving state
|
||||
export const useSaveHandler = ({ file, viewer }) => {
|
||||
const savedFile = React.useMemo(() => viewer?.libraryCids[file.cid], [viewer]);
|
||||
const [state, setState] = React.useState({
|
||||
isSaved: !!savedFile,
|
||||
// NOTE(amine): viewer will have the hydrated state
|
||||
saveCount: file.saveCount,
|
||||
});
|
||||
|
||||
const handleSaveState = () => {
|
||||
setState((prev) => {
|
||||
if (prev.isSaved) {
|
||||
return {
|
||||
isSaved: false,
|
||||
saveCount: prev.saveCount - 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isSaved: true,
|
||||
saveCount: prev.saveCount + 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!viewer) {
|
||||
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
|
||||
return;
|
||||
}
|
||||
// NOTE(amine): optimistic update
|
||||
handleSaveState();
|
||||
const response =
|
||||
state.isSaved && savedFile
|
||||
? await Actions.deleteFiles({ ids: [savedFile.id] })
|
||||
: await Actions.saveCopy({ files: [file] });
|
||||
if (Events.hasError(response)) {
|
||||
// NOTE(amine): revert back to old state if there is an error
|
||||
handleSaveState();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return { save, ...state };
|
||||
};
|
||||
|
||||
export const useFollowProfileHandler = ({ user, viewer, onAction }) => {
|
||||
const [isFollowing, setFollowing] = React.useState(
|
||||
!viewer
|
||||
? false
|
||||
: !!viewer?.following.some((entry) => {
|
||||
return entry.id === user.id;
|
||||
})
|
||||
);
|
||||
|
||||
const handleFollow = async (userId) => {
|
||||
if (!viewer) {
|
||||
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
setFollowing((prev) => !prev);
|
||||
const response = await Actions.createSubscription({
|
||||
userId,
|
||||
});
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
setFollowing((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
onAction({
|
||||
type: "UPDATE_VIEWER",
|
||||
viewer: {
|
||||
following: isFollowing
|
||||
? viewer.following.filter((user) => user.id !== userId)
|
||||
: viewer.following.concat([
|
||||
{
|
||||
id: user.id,
|
||||
data: user.data,
|
||||
fileCount: user.fileCount,
|
||||
followerCount: user.followerCount + 1,
|
||||
slateCount: user.slateCount,
|
||||
username: user.username,
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { handleFollow, isFollowing };
|
||||
};
|
||||
|
@ -47,7 +47,6 @@ export const H2 = css`
|
||||
export const H3 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-size: 1.25rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.017px;
|
||||
|
||||
@ -57,7 +56,6 @@ export const H3 = css`
|
||||
export const H4 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-size: 1rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.011px;
|
||||
|
||||
@ -67,7 +65,6 @@ export const H4 = css`
|
||||
export const H5 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-size: 0.875rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.006px;
|
||||
|
||||
@ -81,6 +78,14 @@ export const P1 = css`
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.011px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
font-family: ${Constants.font.text};
|
||||
font-size: 0.875rem;
|
||||
font-weight: regular;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.006px;
|
||||
}
|
||||
|
||||
${TEXT}
|
||||
`;
|
||||
|
||||
@ -91,6 +96,14 @@ export const P2 = css`
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.006px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
font-family: ${Constants.font.text};
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
${TEXT}
|
||||
`;
|
||||
|
||||
@ -206,3 +219,47 @@ export const MOBILE_ONLY = css`
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
/* COMMON GRIDS */
|
||||
export const OBJECTS_PREVIEW_GRID = css`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
||||
grid-gap: 24px 16px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
grid-gap: 20px 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(166px, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
export const BUTTON_RESET = css`
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
${HOVERABLE}
|
||||
`;
|
||||
|
||||
export const COLLECTIONS_PREVIEW_GRID = css`
|
||||
display: grid;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(432px, 1fr));
|
||||
grid-gap: 24px 16px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.desktop}px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-gap: 20px 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const PROFILE_PREVIEW_GRID = css`
|
||||
display: grid;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(432px, 1fr));
|
||||
grid-gap: 24px 16px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
grid-gap: 20px 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(344px, 1fr));
|
||||
}
|
||||
`;
|
||||
|
@ -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,52 @@ export const SmileCircle = (props) => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FolderPlus = (props) => (
|
||||
<svg width={20} height={21} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M18.333 16.333A1.667 1.667 0 0116.667 18H3.333a1.667 1.667 0 01-1.666-1.667V4.667A1.667 1.667 0 013.333 3H7.5l1.667 2.5h7.5a1.667 1.667 0 011.666 1.667v9.166zM10 9.667v5M7.5 12.167h5"
|
||||
stroke="#48484A"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlayButton = (props) => (
|
||||
<svg width={40} height={40} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g filter="url(#prefix__filter0_b)">
|
||||
<rect width={40} height={40} rx={20} fill="#fff" fillOpacity={0.3} />
|
||||
<path d="M15.333 14l9.334 6-9.334 6V14z" fill="#F2F2F7" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="prefix__filter0_b"
|
||||
x={-75}
|
||||
y={-75}
|
||||
width={190}
|
||||
height={190}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feGaussianBlur in="BackgroundImage" stdDeviation={37.5} />
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_backgroundBlur" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RSS = (props) => (
|
||||
<svg width={20} height={21} fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3.333 9.667a7.5 7.5 0 017.5 7.5M3.333 3.833a13.333 13.333 0 0113.334 13.334M4.167 17.167a.833.833 0 100-1.667.833.833 0 000 1.667z"
|
||||
stroke="#000002"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
@ -83,3 +83,42 @@ export const coerceToArray = (input) => {
|
||||
return [input];
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileExtension = (filename) => filename?.split(".").pop();
|
||||
|
||||
export const getTimeDifferenceFromNow = (date) => {
|
||||
const pastDate = new Date(date);
|
||||
const now = new Date();
|
||||
|
||||
const differenceInSeconds = Math.floor((now - pastDate) / 1000);
|
||||
if (differenceInSeconds < 60) {
|
||||
return differenceInSeconds + "s";
|
||||
}
|
||||
|
||||
const differenceInMinutes = Math.floor(differenceInSeconds / 60);
|
||||
if (differenceInMinutes < 60) {
|
||||
return differenceInMinutes + "m";
|
||||
}
|
||||
|
||||
const differenceInHours = Math.floor(differenceInMinutes / 60);
|
||||
if (differenceInHours < 24) {
|
||||
return differenceInHours + "h";
|
||||
}
|
||||
|
||||
const differenceInDays = Math.floor(differenceInHours / 24);
|
||||
if (differenceInDays < 24) {
|
||||
return differenceInDays + "d";
|
||||
}
|
||||
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
const day = pastDate.getDay();
|
||||
const month = pastDate.toLocaleString("default", { month: "long" });
|
||||
const year = pastDate.getFullYear();
|
||||
|
||||
if (year === currentYear) {
|
||||
return `${day} ${month}`;
|
||||
}
|
||||
|
||||
return `${day} ${month} ${year}`;
|
||||
};
|
||||
|
@ -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,100 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { Link } from "~/components/core/Link";
|
||||
import { motion } from "framer-motion";
|
||||
import { ViewMoreContent, ProfileInfo } from "~/components/core/ActivityGroup/components";
|
||||
|
||||
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
|
||||
|
||||
const STYLES_GROUP_GRID = (theme) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-row-gap: 32px;
|
||||
border-bottom: 1px solid ${theme.semantic.bgLight};
|
||||
padding-bottom: 24px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
grid-row-gap: 24px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ActivityCollectionGroup({ onAction, viewer, group, ...props }) {
|
||||
const { owner, slate, type, createdAt } = group;
|
||||
const { elements, restElements } = React.useMemo(() => {
|
||||
if (!Array.isArray(slate)) {
|
||||
return { elements: [slate] };
|
||||
}
|
||||
return { elements: slate.slice(0, 2), restElements: slate.slice(2) };
|
||||
}, [slate]);
|
||||
|
||||
const [showMore, setShowMore] = React.useState(false);
|
||||
const viewMoreFiles = () => setShowMore(true);
|
||||
|
||||
const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(createdAt);
|
||||
// const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(elements[0].createdAt);
|
||||
const nbrOfFilesUploaded = elements.length + (restElements?.length || 0);
|
||||
const action = React.useMemo(() => {
|
||||
if (type === "SUBSCRIBE_SLATE") {
|
||||
return "started following";
|
||||
}
|
||||
return `created ${nbrOfFilesUploaded} ${Strings.pluralize("collection", nbrOfFilesUploaded)}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div css={STYLES_GROUP_GRID} {...props}>
|
||||
<ProfileInfo
|
||||
time={timeSinceUploaded}
|
||||
owner={owner}
|
||||
viewer={viewer}
|
||||
action={action}
|
||||
onAction={onAction}
|
||||
/>
|
||||
<div>
|
||||
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
|
||||
{elements.map((collection) => (
|
||||
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
|
||||
<CollectionPreviewBlock collection={collection} viewer={viewer} />
|
||||
</Link>
|
||||
))}
|
||||
{showMore &&
|
||||
restElements.map((collection, i) =>
|
||||
// NOTE(amine): animate only the first 8 elements
|
||||
i < 8 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={collection.id}
|
||||
>
|
||||
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
|
||||
<CollectionPreviewBlock collection={collection} viewer={viewer} />
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
|
||||
<CollectionPreviewBlock collection={collection} viewer={viewer} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div css={STYLES_VIEWMORE_CONTAINER}>
|
||||
{!showMore && restElements?.length ? (
|
||||
<ViewMoreContent onClick={viewMoreFiles}>
|
||||
View {restElements.length} more {Strings.pluralize("collection", restElements.length)}
|
||||
</ViewMoreContent>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
111
components/core/ActivityGroup/components/ActivityFileGroup.js
Normal file
111
components/core/ActivityGroup/components/ActivityFileGroup.js
Normal file
@ -0,0 +1,111 @@
|
||||
import * as React from "react";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ViewMoreContent, ProfileInfo } from "~/components/core/ActivityGroup/components";
|
||||
|
||||
import ObjectPreview from "~/components/core/ObjectPreview";
|
||||
|
||||
const STYLES_OBJECT_GRID = (theme) => css`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
||||
grid-gap: 20px 12px;
|
||||
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
grid-row-gap: 24px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(169px, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_GROUP_GRID = (theme) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-row-gap: 32px;
|
||||
border-bottom: 1px solid ${theme.semantic.bgLight};
|
||||
padding-bottom: 24px;
|
||||
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ActivityFileGroup({ viewer, group, onAction }) {
|
||||
const { file, owner, slate, type, createdAt } = group;
|
||||
|
||||
const { elements, restElements } = React.useMemo(() => {
|
||||
if (!Array.isArray(file)) {
|
||||
return { elements: [file] };
|
||||
}
|
||||
return { elements: file.slice(0, 4), restElements: file.slice(4) };
|
||||
}, [file]);
|
||||
|
||||
const [showMore, setShowMore] = React.useState(false);
|
||||
const viewMoreFiles = () => setShowMore(true);
|
||||
|
||||
const timeSinceAction = Utilities.getTimeDifferenceFromNow(createdAt);
|
||||
const nbrOfFiles = elements.length + (restElements?.length || 0);
|
||||
const action = React.useMemo(() => {
|
||||
if (type === "CREATE_FILE")
|
||||
return `uploaded ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)} ${
|
||||
slate ? `to ${slate.slatename}` : ""
|
||||
}`;
|
||||
|
||||
if (type === "LIKE_FILE") return `liked ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)}`;
|
||||
|
||||
if (type === "SAVE_COPY") return `saved ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)}`;
|
||||
|
||||
return `added ${nbrOfFiles} ${Strings.pluralize("file", nbrOfFiles)} ${
|
||||
slate && `to ${slate.slatename}`
|
||||
}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div css={STYLES_GROUP_GRID}>
|
||||
<ProfileInfo
|
||||
time={timeSinceAction}
|
||||
owner={owner}
|
||||
action={action}
|
||||
viewer={viewer}
|
||||
onAction={onAction}
|
||||
/>
|
||||
<div>
|
||||
<div css={STYLES_OBJECT_GRID}>
|
||||
{elements.map((file) => (
|
||||
<ObjectPreview viewer={viewer} owner={file.owner} key={file.id} file={file} />
|
||||
))}
|
||||
{showMore &&
|
||||
restElements.map((file, i) =>
|
||||
// NOTE(amine): animate only the first 8 elements
|
||||
i < 8 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={file.id}
|
||||
>
|
||||
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ObjectPreview viewer={viewer} owner={file.owner} file={file} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div css={STYLES_VIEWMORE_CONTAINER}>
|
||||
{!showMore && restElements?.length ? (
|
||||
<ViewMoreContent items={restElements} onClick={viewMoreFiles}>
|
||||
View {restElements.length} more {Strings.pluralize("file", restElements.length)}
|
||||
</ViewMoreContent>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
104
components/core/ActivityGroup/components/ActivityProfileGroup.js
Normal file
104
components/core/ActivityGroup/components/ActivityProfileGroup.js
Normal file
@ -0,0 +1,104 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { ProfileInfo } from "~/components/core/ActivityGroup/components";
|
||||
import { Link } from "~/components/core/Link";
|
||||
import { motion } from "framer-motion";
|
||||
import { ViewMoreContent } from "~/components/core/ActivityGroup/components";
|
||||
|
||||
import ProfilePreview from "~/components/core/ProfilePreviewBlock";
|
||||
|
||||
const STYLES_GROUP_GRID = (theme) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-row-gap: 32px;
|
||||
border-bottom: 1px solid ${theme.semantic.bgLight};
|
||||
padding-bottom: 24px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
grid-row-gap: 24px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_VIEWMORE_CONTAINER = (theme) => css`
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ActivityProfileGroup({ viewer, external, group, onAction }) {
|
||||
const { owner, user, createdAt } = group;
|
||||
|
||||
const { elements, restElements } = React.useMemo(() => {
|
||||
if (!Array.isArray(user)) {
|
||||
return { elements: [user] };
|
||||
}
|
||||
return { elements: user.slice(0, 3), restElements: user.slice(3) };
|
||||
}, [user]);
|
||||
|
||||
const [showMore, setShowMore] = React.useState(false);
|
||||
const viewMoreFiles = () => setShowMore(true);
|
||||
|
||||
const timeSinceUploaded = Utilities.getTimeDifferenceFromNow(createdAt);
|
||||
|
||||
return (
|
||||
<div css={STYLES_GROUP_GRID}>
|
||||
<ProfileInfo
|
||||
time={timeSinceUploaded}
|
||||
owner={owner}
|
||||
action={"started following"}
|
||||
viewer={viewer}
|
||||
onAction={onAction}
|
||||
/>
|
||||
<div>
|
||||
<div css={Styles.PROFILE_PREVIEW_GRID}>
|
||||
{elements.map((user) => (
|
||||
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
|
||||
<ProfilePreview
|
||||
onAction={onAction}
|
||||
viewer={viewer}
|
||||
external={external}
|
||||
profile={user}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{showMore &&
|
||||
restElements.map((user, i) =>
|
||||
// NOTE(amine): animate only the first 8 elements
|
||||
i < 8 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={user.id}
|
||||
>
|
||||
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
|
||||
<ProfilePreview
|
||||
onAction={onAction}
|
||||
viewer={viewer}
|
||||
external={external}
|
||||
profile={user}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Link key={user.id} href={`/$/user/${user.id}`} onAction={onAction}>
|
||||
<ProfilePreview onAction={onAction} profile={user} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div css={STYLES_VIEWMORE_CONTAINER}>
|
||||
{!showMore && restElements?.length ? (
|
||||
<ViewMoreContent onClick={viewMoreFiles}>
|
||||
View {restElements.length} more {Strings.pluralize("profile", restElements.length)}
|
||||
</ViewMoreContent>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
101
components/core/ActivityGroup/components/ProfileInfo.js
Normal file
101
components/core/ActivityGroup/components/ProfileInfo.js
Normal file
@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { Link } from "~/components/core/Link";
|
||||
import { css } from "@emotion/react";
|
||||
import { P2, H4 } from "~/components/system/components/Typography";
|
||||
import { ButtonPrimary, ButtonTertiary } from "~/components/system";
|
||||
import { useFollowProfileHandler } from "~/common/hooks";
|
||||
|
||||
const STYLES_PROFILE_CONTAINER = css`
|
||||
display: flex;
|
||||
padding-right: 12px;
|
||||
box-sizing: border-box;
|
||||
& > * + * {
|
||||
margin-left: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_TEXT_BLACK = (theme) =>
|
||||
css`
|
||||
color: ${theme.semantic.textBlack};
|
||||
display: inline;
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE = css`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const STYLES_MOBILE_ALIGN = (theme) => css`
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
width: 100%;
|
||||
& > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ProfileInfo({ owner, viewer, time, action, onAction }) {
|
||||
const { isFollowing, handleFollow } = useFollowProfileHandler({ viewer, user: owner, onAction });
|
||||
const { username, data = {} } = owner;
|
||||
const { photo } = data;
|
||||
|
||||
const isOwner = viewer?.id === owner.id;
|
||||
return (
|
||||
<Link href={`/$/user/${owner.id}`} onAction={onAction}>
|
||||
<div css={STYLES_PROFILE_CONTAINER}>
|
||||
<img src={photo} alt={`${username} profile`} css={STYLES_PROFILE} />
|
||||
<div css={STYLES_MOBILE_ALIGN}>
|
||||
<span>
|
||||
<H4 color="textBlack" css={[STYLES_TEXT_BLACK, Styles.HEADING_04]}>
|
||||
{username}
|
||||
</H4>
|
||||
<H4
|
||||
color="textBlack"
|
||||
css={[STYLES_TEXT_BLACK, Styles.HEADING_04, Styles.MOBILE_HIDDEN]}
|
||||
>
|
||||
•
|
||||
</H4>
|
||||
<P2 color="textGrayDark" style={{ display: "inline" }}>
|
||||
{time}
|
||||
</P2>
|
||||
</span>
|
||||
<P2 color="textGrayDark" nbrOflines={2}>
|
||||
{action}
|
||||
</P2>
|
||||
{!isOwner && (
|
||||
<div style={{ marginTop: 12 }} css={Styles.MOBILE_HIDDEN}>
|
||||
{isFollowing ? (
|
||||
<ButtonTertiary
|
||||
style={{ marginTop: "auto", maxWidth: "91px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleFollow(owner.id);
|
||||
}}
|
||||
>
|
||||
Following
|
||||
</ButtonTertiary>
|
||||
) : (
|
||||
<ButtonPrimary
|
||||
style={{ marginTop: "auto", maxWidth: "91px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleFollow(owner.id);
|
||||
}}
|
||||
>
|
||||
Follow
|
||||
</ButtonPrimary>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
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.4} 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";
|
46
components/core/ActivityGroup/index.js
Normal file
46
components/core/ActivityGroup/index.js
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import {
|
||||
ActivityFileGroup,
|
||||
ActivityCollectionGroup,
|
||||
ActivityProfileGroup,
|
||||
} from "~/components/core/ActivityGroup/components";
|
||||
|
||||
const STYLES_GROUP_GRID = (theme) => css`
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-row-gap: 32px;
|
||||
border-bottom: 1px solid ${theme.semantic.bgLight};
|
||||
padding-bottom: 24px;
|
||||
`;
|
||||
|
||||
export default function ActivityGroup({ onAction, viewer, external, group }) {
|
||||
const { type } = group;
|
||||
if (
|
||||
type === "CREATE_FILE" ||
|
||||
type === "CREATE_SLATE_OBJECT" ||
|
||||
type === "LIKE_FILE" ||
|
||||
type === "SAVE_COPY"
|
||||
) {
|
||||
return <ActivityFileGroup viewer={viewer} onAction={onAction} group={group} />;
|
||||
}
|
||||
|
||||
if (type === "CREATE_SLATE" || type === "SUBSCRIBE_SLATE") {
|
||||
return <ActivityCollectionGroup onAction={onAction} viewer={viewer} group={group} />;
|
||||
}
|
||||
|
||||
if (type === "SUBSCRIBE_USER") {
|
||||
return (
|
||||
<ActivityProfileGroup onAction={onAction} viewer={viewer} external={external} group={group} />
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(amine): grouping for making files/slate public
|
||||
return (
|
||||
<div css={STYLES_GROUP_GRID}>
|
||||
<div>{type}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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%,
|
||||
|
259
components/core/CollectionPreviewBlock/FilesCollectionPreview.js
Normal file
259
components/core/CollectionPreviewBlock/FilesCollectionPreview.js
Normal file
@ -0,0 +1,259 @@
|
||||
import * as React from "react";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Typography from "~/components/system/components/Typography";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { Divider } from "~/components/system/components/Divider";
|
||||
import { Logo } from "~/common/logo";
|
||||
import { css } from "@emotion/react";
|
||||
import { LikeButton, SaveButton } from "~/components/core/ObjectPreview/components";
|
||||
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
|
||||
import { FollowButton } from "~/components/core/CollectionPreviewBlock/components";
|
||||
import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks";
|
||||
|
||||
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
|
||||
|
||||
const STYLES_CONTAINER = (theme) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: ${theme.system.white};
|
||||
height: 311px;
|
||||
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
height: 281px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
background-color: ${theme.system.white};
|
||||
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
||||
background-color: ${theme.semantic.bgGrayLight};
|
||||
-webkit-backdrop-filter: blur(75px);
|
||||
backdrop-filter: blur(75px);
|
||||
}
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
padding: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_SPACE_BETWEEN = css`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const STYLES_CONTROLLS = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const STYLES_TEXT_BLACK = (theme) => css`
|
||||
color: ${theme.semantic.textBlack};
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE_IMAGE = (theme) => css`
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
padding: 4px 16px;
|
||||
border: none;
|
||||
background-color: unset;
|
||||
div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${theme.system.gray};
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_METRICS = (theme) => css`
|
||||
margin-top: 16px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
${Styles.CONTAINER_CENTERED};
|
||||
${STYLES_SPACE_BETWEEN};
|
||||
`;
|
||||
|
||||
const STYLES_FILES_PREVIEWS = (theme) => css`
|
||||
flex-grow: 1;
|
||||
padding: 16px;
|
||||
padding-right: 0px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
padding: 12px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_PLACEHOLDER = css`
|
||||
height: 64px;
|
||||
min-width: 86px;
|
||||
width: 86px;
|
||||
`;
|
||||
|
||||
const CollectionPreviewFile = ({ file, viewer }) => {
|
||||
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
|
||||
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
|
||||
|
||||
const title = file.data.name || file.filename;
|
||||
const { body } = file.data;
|
||||
|
||||
return (
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER]}>
|
||||
<ObjectPlaceholder ratio={1.1} file={file} containerCss={STYLES_PLACEHOLDER} showTag />
|
||||
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
|
||||
<Typography.H5 color="textBlack" nbrOflines={1}>
|
||||
{title}
|
||||
</Typography.H5>
|
||||
<Typography.P3 nbrOflines={1} color="textGrayDark">
|
||||
{body}
|
||||
</Typography.P3>
|
||||
<div style={{ marginTop: "auto" }} css={Styles.HORIZONTAL_CONTAINER}>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<LikeButton isLiked={isLiked} onClick={like} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{likeCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
<div style={{ marginLeft: 48 }} css={Styles.CONTAINER_CENTERED}>
|
||||
<SaveButton onSave={save} isSaved={isSaved} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{saveCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useCollectionCarrousel = ({ objects }) => {
|
||||
const [selectedIdx, setSelectedIdx] = React.useState(0);
|
||||
const selectBatchIdx = (idx) => setSelectedIdx(idx);
|
||||
const selectedBatch = objects[selectedIdx];
|
||||
return { selectBatchIdx, selectedBatch, selectedIdx };
|
||||
};
|
||||
|
||||
export default function CollectionPreview({ collection, viewer }) {
|
||||
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
|
||||
const filePreviews = React.useMemo(() => {
|
||||
const files = collection?.objects || [];
|
||||
let previews = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const batch = [];
|
||||
if (files[i * 2]) batch.push(files[i * 2]);
|
||||
if (files[i * 2 + 1]) batch.push(files[i * 2 + 1]);
|
||||
if (batch.length > 0) previews.push(batch);
|
||||
if (previews.length === 3 || batch.length < 2) break;
|
||||
}
|
||||
return previews;
|
||||
}, [collection]);
|
||||
|
||||
const { selectBatchIdx, selectedBatch, selectedIdx } = useCollectionCarrousel({
|
||||
objects: filePreviews,
|
||||
});
|
||||
|
||||
const nbrOfFiles = collection?.objects?.length || 0;
|
||||
const isCollectionEmpty = nbrOfFiles === 0;
|
||||
|
||||
const showFollowButton = collection.ownerId !== viewer?.id;
|
||||
|
||||
return (
|
||||
<div css={STYLES_CONTAINER}>
|
||||
<div css={STYLES_FILES_PREVIEWS} style={{ display: "flex" }}>
|
||||
<div style={{ width: "100%" }}>
|
||||
{!isCollectionEmpty ? (
|
||||
selectedBatch.map((file, i) => (
|
||||
<React.Fragment key={file.id}>
|
||||
{i === 1 && <Divider color="bgLight" style={{ margin: "8px 0px" }} />}
|
||||
<CollectionPreviewFile file={file} viewer={viewer} />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
css={[Styles.CONTAINER_CENTERED, Styles.VERTICAL_CONTAINER]}
|
||||
>
|
||||
<Logo style={{ height: 18, marginBottom: 8 }} />
|
||||
<Typography.P1 color="textGrayDark">No files in this collection</Typography.P1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
<div css={STYLES_CONTROLLS}>
|
||||
{filePreviews.map((preview, i) => (
|
||||
<button
|
||||
key={i}
|
||||
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
selectBatchIdx(i);
|
||||
}}
|
||||
aria-label="Next Preview Image"
|
||||
>
|
||||
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div css={STYLES_DESCRIPTION_CONTAINER}>
|
||||
<div>
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
|
||||
<Typography.H4 css={[Styles.HEADING_04, STYLES_TEXT_BLACK]}>
|
||||
{collection.slatename}
|
||||
</Typography.H4>
|
||||
<Typography.P2 color="textGrayDark">
|
||||
{nbrOfFiles} {Strings.pluralize("Object", nbrOfFiles)}
|
||||
</Typography.P2>
|
||||
</div>
|
||||
|
||||
{collection?.data?.body && (
|
||||
<Typography.P2 style={{ marginTop: 4 }} nbrOflines={2} color="textGrayDark">
|
||||
{collection?.data?.body}
|
||||
</Typography.P2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div css={[STYLES_METRICS]}>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<FollowButton isFollowed={isFollowed} onFollow={showFollowButton && follow} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{followCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<img
|
||||
css={STYLES_PROFILE_IMAGE}
|
||||
src="https://slate.textile.io/ipfs/bafkreick3nscgixwfpq736forz7kzxvvhuej6kszevpsgmcubyhsx2pf7i"
|
||||
alt="owner profile"
|
||||
/>
|
||||
<Typography.P2 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
Wes Anderson
|
||||
</Typography.P2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
235
components/core/CollectionPreviewBlock/ImageCollectionPreview.js
Normal file
235
components/core/CollectionPreviewBlock/ImageCollectionPreview.js
Normal file
@ -0,0 +1,235 @@
|
||||
import * as React from "react";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Typography from "~/components/system/components/Typography";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { useInView } from "~/common/hooks";
|
||||
import { isBlurhashValid } from "blurhash";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import { css } from "@emotion/react";
|
||||
import { FollowButton } from "~/components/core/CollectionPreviewBlock/components";
|
||||
import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks";
|
||||
|
||||
const STYLES_CONTAINER = (theme) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
height: 311px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
height: 281px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_PREVIEW = (theme) => css`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: ${theme.system.white};
|
||||
background-size: cover;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: ${theme.system.white};
|
||||
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
||||
background-color: ${theme.semantic.bgBlurLight6OP};
|
||||
-webkit-backdrop-filter: blur(75px);
|
||||
backdrop-filter: blur(75px);
|
||||
}
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
const STYLES_SPACE_BETWEEN = css`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const STYLES_CONTROLLS = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
padding: 16px 0px 16px 16px;
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE_IMAGE = (theme) => css`
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
padding: 4px 16px;
|
||||
border: none;
|
||||
background-color: unset;
|
||||
div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${theme.system.gray};
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_METRICS = (theme) => css`
|
||||
margin-top: 16px;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
${Styles.CONTAINER_CENTERED};
|
||||
${STYLES_SPACE_BETWEEN}
|
||||
`;
|
||||
|
||||
const getFileBlurHash = (file) => {
|
||||
const coverImage = file?.data?.coverImage;
|
||||
const coverImageBlurHash = coverImage?.data?.blurhash;
|
||||
if (coverImage && isBlurhashValid(coverImageBlurHash)) return coverImageBlurHash;
|
||||
|
||||
const blurhash = file?.data?.blurhash;
|
||||
if (isBlurhashValid(blurhash)) return blurhash;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useCollectionCarrousel = ({ objects }) => {
|
||||
const [selectedIdx, setSelectedIdx] = React.useState(0);
|
||||
const selectImageByIdx = (idx) => setSelectedIdx(idx);
|
||||
|
||||
const [imagesLoadedIdx, setImagesLoadedIdx] = React.useState({});
|
||||
const handleLoading = () => setImagesLoadedIdx((prev) => ({ ...prev, [selectedIdx]: true }));
|
||||
|
||||
const isCurrentImageLoaded = imagesLoadedIdx[selectedIdx];
|
||||
const { image: selectedImage, blurhash } = objects[selectedIdx];
|
||||
return {
|
||||
selectedImage,
|
||||
selectedIdx,
|
||||
isLoaded: isCurrentImageLoaded,
|
||||
blurhash,
|
||||
handleLoading,
|
||||
selectImageByIdx,
|
||||
};
|
||||
};
|
||||
|
||||
export default function ImageCollectionPreview({ collection, viewer }) {
|
||||
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
|
||||
|
||||
const filePreviews = React.useMemo(() => {
|
||||
const previews = collection.objects.map((object) => {
|
||||
const coverImage = object?.data?.coverImage;
|
||||
const image = coverImage || Strings.getURLfromCID(object.cid);
|
||||
const blurhash = getFileBlurHash(object);
|
||||
return { image, blurhash };
|
||||
});
|
||||
return previews.slice(0, 3);
|
||||
}, [collection]);
|
||||
|
||||
const previewerRef = React.useRef();
|
||||
const { isInView } = useInView({
|
||||
ref: previewerRef,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoaded,
|
||||
blurhash,
|
||||
selectedImage,
|
||||
handleLoading,
|
||||
selectedIdx,
|
||||
selectImageByIdx,
|
||||
} = useCollectionCarrousel({ objects: filePreviews });
|
||||
|
||||
const nbrOfFiles = collection?.objects?.length || 0;
|
||||
|
||||
const showFollowButton = collection.ownerId !== viewer?.id;
|
||||
return (
|
||||
<div ref={previewerRef} css={STYLES_CONTAINER}>
|
||||
{isInView && (
|
||||
<div css={STYLES_PREVIEW}>
|
||||
{!isLoaded && blurhash && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
height="100%"
|
||||
width="100%"
|
||||
resolutionX={32}
|
||||
resolutionY={32}
|
||||
punch={1}
|
||||
/>
|
||||
)}
|
||||
<img src={selectedImage} alt="Collection preview" onLoad={handleLoading} />
|
||||
</div>
|
||||
)}
|
||||
<div css={STYLES_CONTROLLS}>
|
||||
{filePreviews.map((preview, i) => (
|
||||
<button
|
||||
key={preview.image}
|
||||
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
selectImageByIdx(i);
|
||||
}}
|
||||
aria-label="Next Preview Image"
|
||||
>
|
||||
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div css={STYLES_DESCRIPTION_CONTAINER}>
|
||||
<div>
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
|
||||
<Typography.H4 color="textBlack" nbrOflines={1}>
|
||||
{collection.slatename}
|
||||
</Typography.H4>
|
||||
<Typography.P2 color="textGrayDark">
|
||||
{nbrOfFiles} {Strings.pluralize("Object", nbrOfFiles)}
|
||||
</Typography.P2>
|
||||
</div>
|
||||
{collection?.data?.body && (
|
||||
<Typography.P2 style={{ marginTop: 4 }} nbrOflines={2} color="textGrayDark">
|
||||
{collection?.data?.body}
|
||||
</Typography.P2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div css={STYLES_METRICS}>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<FollowButton onFollow={showFollowButton && follow} isFollowed={isFollowed} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{followCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<img
|
||||
css={STYLES_PROFILE_IMAGE}
|
||||
src="https://slate.textile.io/ipfs/bafkreick3nscgixwfpq736forz7kzxvvhuej6kszevpsgmcubyhsx2pf7i"
|
||||
alt="owner profile"
|
||||
/>
|
||||
<Typography.P2 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
Wes Anderson
|
||||
</Typography.P2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { motion, useAnimation } from "framer-motion";
|
||||
|
||||
const STYLES_BUTTON_HOVER = (theme) => css`
|
||||
:hover .button_path {
|
||||
stroke: ${theme.system.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_INLINE = css`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const animate = async (controls) => {
|
||||
await controls.start({ x: -2, y: 2 });
|
||||
await controls.start({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const useMounted = (callback, depedencies) => {
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useLayoutEffect(() => {
|
||||
if (mountedRef.current && callback) {
|
||||
callback();
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, depedencies);
|
||||
};
|
||||
|
||||
export default function FollowButton({ onFollow, isFollowed, disabled, ...props }) {
|
||||
const controls = useAnimation();
|
||||
|
||||
useMounted(() => {
|
||||
if (isFollowed) {
|
||||
animate(controls);
|
||||
return;
|
||||
}
|
||||
}, [isFollowed]);
|
||||
|
||||
return (
|
||||
<button
|
||||
css={[Styles.BUTTON_RESET, STYLES_INLINE, !disabled && STYLES_BUTTON_HOVER]}
|
||||
onClick={(e) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onFollow) onFollow();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="M3.33334 9.66669C5.32247 9.66669 7.23012 10.4569 8.63664 11.8634C10.0432 13.2699 10.8333 15.1776 10.8333 17.1667"
|
||||
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<motion.path
|
||||
d="M3.33334 3.83331C6.86956 3.83331 10.2609 5.23807 12.7614 7.73856C15.2619 10.239 16.6667 13.6304 16.6667 17.1666"
|
||||
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<motion.path
|
||||
d="M4.16668 17.1667C4.62691 17.1667 5.00001 16.7936 5.00001 16.3333C5.00001 15.8731 4.62691 15.5 4.16668 15.5C3.70644 15.5 3.33334 15.8731 3.33334 16.3333C3.33334 16.7936 3.70644 17.1667 4.16668 17.1667Z"
|
||||
animate={{ stroke: isFollowed ? Constants.system.blue : "#000002" }}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
@ -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 };
|
||||
};
|
17
components/core/CollectionPreviewBlock/index.js
Normal file
17
components/core/CollectionPreviewBlock/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as Validations from "~/common/validations";
|
||||
|
||||
import ImageCollectionPreview from "./ImageCollectionPreview";
|
||||
import FilesCollectionPreview from "./FilesCollectionPreview";
|
||||
|
||||
export default function CollectionPreview({ collection, viewer }) {
|
||||
const objects = collection.objects.filter((file) =>
|
||||
Validations.isPreviewableImage(file.data.type)
|
||||
);
|
||||
|
||||
if (objects.length > 0) {
|
||||
return <ImageCollectionPreview collection={{ ...collection, objects }} viewer={viewer} />;
|
||||
}
|
||||
|
||||
return <FilesCollectionPreview collection={collection} viewer={viewer} />;
|
||||
}
|
@ -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>
|
||||
|
@ -91,7 +91,7 @@ export default function LinkCard({ file }) {
|
||||
<img src={image} style={{ width: "100%" }} />
|
||||
</div>
|
||||
<div css={Styles.VERTICAL_CONTAINER_CENTERED}>
|
||||
<System.H3 style={{ marginBottom: 16, color: Constants.system.textBlack }}>
|
||||
<System.H3 style={{ marginBottom: 16, color: Constants.semantic.textBlack }}>
|
||||
{name}
|
||||
</System.H3>
|
||||
<LinkTag url={url} style={{ marginBottom: 16 }} />
|
||||
|
38
components/core/ObjectPreview/3dObjectPreview.js
Normal file
38
components/core/ObjectPreview/3dObjectPreview.js
Normal file
@ -0,0 +1,38 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import ObjectPlaceholder from "./placeholders/3D";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function Object3DPreview(props) {
|
||||
return (
|
||||
<ObjectPreviewPremitive {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<ObjectPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>3D</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
41
components/core/ObjectPreview/AudioObjectPreview.js
Normal file
41
components/core/ObjectPreview/AudioObjectPreview.js
Normal file
@ -0,0 +1,41 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import AudioPlaceholder from "./placeholders/Audio";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 23.7%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function AudioObjectPreview({ file, ...props }) {
|
||||
const tag = Utilities.getFileExtension(file.filename) || "audio";
|
||||
return (
|
||||
<ObjectPreviewPremitive file={file} {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<AudioPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>{tag}</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
40
components/core/ObjectPreview/CodeObjectPreview.js
Normal file
40
components/core/ObjectPreview/CodeObjectPreview.js
Normal file
@ -0,0 +1,40 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import CodePlaceholder from "./placeholders/Code";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 27%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function CodeObjectPreview({ file, ...props }) {
|
||||
const tag = Utilities.getFileExtension(file.filename) || "code";
|
||||
return (
|
||||
<ObjectPreviewPremitive file={file} {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<CodePlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>{tag}</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
36
components/core/ObjectPreview/DefaultObjectPreview.js
Normal file
36
components/core/ObjectPreview/DefaultObjectPreview.js
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import FilePlaceholder from "./placeholders/File";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 26%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function DefaultObjectPreview(props) {
|
||||
return (
|
||||
<ObjectPreviewPremitive {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<FilePlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>FILE</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
38
components/core/ObjectPreview/EpubObjectPreview.js
Normal file
38
components/core/ObjectPreview/EpubObjectPreview.js
Normal file
@ -0,0 +1,38 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import EpubPlaceholder from "./placeholders/EPUB";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 32%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function EpubObjectPreview(props) {
|
||||
return (
|
||||
<ObjectPreviewPremitive {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<EpubPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>EPUB</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
40
components/core/ObjectPreview/FontObjectPreview.js
Normal file
40
components/core/ObjectPreview/FontObjectPreview.js
Normal file
@ -0,0 +1,40 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import { useFont } from "~/components/core/FontFrame/hooks";
|
||||
|
||||
const STYLES_TEXT_PREVIEW = (theme) => css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
margin: 8px;
|
||||
background-color: ${theme.system.white};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${theme.shadow.large};
|
||||
`;
|
||||
|
||||
const STYLES_LETTER = (theme) => css`
|
||||
transform: translateY(-25%);
|
||||
overflow: hidden;
|
||||
font-size: ${theme.typescale.lvl8};
|
||||
`;
|
||||
|
||||
export default function FontObjectPreview({ file, ...props }) {
|
||||
const { fontName } = useFont({ cid: file.cid }, [file.cid]);
|
||||
|
||||
const tag = Utilities.getFileExtension(file.filename) || "font";
|
||||
return (
|
||||
<ObjectPreviewPremitive tag={tag} file={file} {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_TEXT_PREVIEW]}>
|
||||
<div style={{ fontFamily: fontName }}>
|
||||
<div css={STYLES_LETTER}>Aa</div>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
91
components/core/ObjectPreview/ImageObjectPreview.js
Normal file
91
components/core/ObjectPreview/ImageObjectPreview.js
Normal file
@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import { AspectRatio } from "~/components/system";
|
||||
import { useInView } from "~/common/hooks";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import { isBlurhashValid } from "blurhash";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
|
||||
const STYLES_PLACEHOLDER_ABSOLUTE = css`
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
const STYLES_FLUID_CONTAINER = css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_IMAGE = css`
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const ImagePlaceholder = ({ blurhash }) => (
|
||||
<div css={STYLES_PLACEHOLDER_ABSOLUTE}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
|
||||
<AspectRatio ratio={186 / 302}>
|
||||
<div>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
height="100%"
|
||||
width="100%"
|
||||
resolutionX={32}
|
||||
resolutionY={32}
|
||||
punch={1}
|
||||
/>
|
||||
</div>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ImageObjectPreview({ url, file, ...props }) {
|
||||
const previewerRef = React.useRef();
|
||||
const [isLoading, setLoading] = React.useState(true);
|
||||
const handleOnLoaded = () => setLoading(false);
|
||||
|
||||
const { isInView } = useInView({
|
||||
ref: previewerRef,
|
||||
});
|
||||
|
||||
const { type, coverImage } = file.data;
|
||||
const tag = type.split("/")[1];
|
||||
|
||||
const blurhash = React.useMemo(() => {
|
||||
return file.data.blurhash && isBlurhashValid(file.data.blurhash)
|
||||
? file.data.blurhash
|
||||
: coverImage?.data.blurhash && isBlurhashValid(coverImage?.data.blurhash)
|
||||
? coverImage?.data.blurhash
|
||||
: null;
|
||||
}, [file]);
|
||||
|
||||
const shouldShowPlaceholder = isLoading && blurhash;
|
||||
|
||||
const imageUrl = coverImage ? Strings.getURLfromCID(coverImage?.cid) : url;
|
||||
|
||||
return (
|
||||
<ObjectPreviewPremitive file={file} tag={tag} isImage {...props}>
|
||||
<div ref={previewerRef} css={[Styles.CONTAINER_CENTERED, STYLES_FLUID_CONTAINER]}>
|
||||
{isInView && (
|
||||
<AspectRatio ratio={186 / 302}>
|
||||
{/** NOTE(amine): if it's loaded */}
|
||||
<img
|
||||
css={STYLES_IMAGE}
|
||||
src={imageUrl}
|
||||
alt={`${file.name} preview`}
|
||||
onLoad={handleOnLoaded}
|
||||
/>
|
||||
</AspectRatio>
|
||||
)}
|
||||
{shouldShowPlaceholder && <ImagePlaceholder blurhash={blurhash} />}
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
38
components/core/ObjectPreview/KeynoteObjectPreview.js
Normal file
38
components/core/ObjectPreview/KeynoteObjectPreview.js
Normal file
@ -0,0 +1,38 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import KeynotePlaceholder from "./placeholders/Keynote";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 36%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function KeynoteObjectPreview(props) {
|
||||
return (
|
||||
<ObjectPreviewPremitive {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<KeynotePlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>KEYNOTE</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
171
components/core/ObjectPreview/ObjectPreviewPremitive.js
Normal file
171
components/core/ObjectPreview/ObjectPreviewPremitive.js
Normal file
@ -0,0 +1,171 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { H4, P2, P3 } from "~/components/system/components/Typography";
|
||||
import { AspectRatio } from "~/components/system";
|
||||
import { LikeButton, SaveButton } from "./components";
|
||||
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
|
||||
import { Link } from "~/components/core/Link";
|
||||
|
||||
import ImageObjectPreview from "./ImageObjectPreview";
|
||||
|
||||
const STYLES_BACKGROUND_LIGHT = (theme) => css`
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
box-shadow: 0 0 0 1px ${theme.semantic.bgLight};
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const STYLES_WRAPPER = css`
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const STYLES_DESCRIPTION = (theme) => css`
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 12px 16px 12px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: ${theme.system.white};
|
||||
|
||||
@supports ((-webkit-backdrop-filter: blur(75px)) or (backdrop-filter: blur(75px))) {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 100%);
|
||||
backdrop-filter: blur(75px);
|
||||
-webkit-backdrop-filter: blur(75px);
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
padding: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_DESCRIPTION_META = css`
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const STYLES_REACTIONS_CONTAINER = css`
|
||||
display: flex;
|
||||
& > * + * {
|
||||
margin-left: 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_REACTION = css`
|
||||
display: flex;
|
||||
& > * + * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE_IMAGE = css`
|
||||
display: block;
|
||||
background-color: ${Constants.semantic.bgLight};
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const STYLES_DESCRIPTION_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
top: -32px;
|
||||
left: 12px;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid ${theme.system.grayLight5};
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const STYLES_SELECTED_RING = (theme) => css`
|
||||
box-shadow: 0 0 0 2px ${theme.system.blue};
|
||||
`;
|
||||
|
||||
export default function ObjectPreviewPremitive({
|
||||
children,
|
||||
tag,
|
||||
file,
|
||||
isSelected,
|
||||
viewer,
|
||||
owner,
|
||||
// NOTE(amine): internal prop used to display
|
||||
isImage,
|
||||
onAction,
|
||||
}) {
|
||||
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
|
||||
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
|
||||
|
||||
const title = file.data.name || file.filename;
|
||||
|
||||
if (file?.data?.coverImage && !isImage) {
|
||||
return (
|
||||
<ImageObjectPreview file={file} owner={owner} isSelected={isSelected} onAction={onAction} />
|
||||
);
|
||||
}
|
||||
const showSaveButton = viewer?.id !== file?.ownerId;
|
||||
return (
|
||||
<div
|
||||
css={[
|
||||
css({
|
||||
boxShadow: `0 0 0 0px ${Constants.system.blue}`,
|
||||
transition: "box-shadow 0.2s",
|
||||
borderRadius: 8,
|
||||
}),
|
||||
isSelected && STYLES_SELECTED_RING,
|
||||
]}
|
||||
>
|
||||
<AspectRatio ratio={295 / 248} css={STYLES_BACKGROUND_LIGHT}>
|
||||
<div css={STYLES_WRAPPER}>
|
||||
<AspectRatio ratio={1}>
|
||||
<div>{children}</div>
|
||||
</AspectRatio>
|
||||
|
||||
<article css={STYLES_DESCRIPTION}>
|
||||
{tag && (
|
||||
<div css={STYLES_DESCRIPTION_TAG}>
|
||||
<P3>{tag}</P3>
|
||||
</div>
|
||||
)}
|
||||
<H4 nbrOflines={1} color="textBlack">
|
||||
{title}
|
||||
</H4>
|
||||
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_DESCRIPTION_META]}>
|
||||
<div css={STYLES_REACTIONS_CONTAINER}>
|
||||
<div css={STYLES_REACTION}>
|
||||
<LikeButton onClick={like} isLiked={isLiked} />
|
||||
<P2 color="textGrayDark">{likeCount}</P2>
|
||||
</div>
|
||||
{showSaveButton && (
|
||||
<div css={STYLES_REACTION}>
|
||||
<SaveButton onSave={save} isSaved={isSaved} />
|
||||
<P2 color="textGrayDark">{saveCount}</P2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{owner && (
|
||||
<Link
|
||||
href={`/$/user/${owner.id}`}
|
||||
onAction={onAction}
|
||||
aria-label={`Visit ${owner.username}'s profile`}
|
||||
title={`Visit ${owner.username}'s profile`}
|
||||
>
|
||||
<img
|
||||
css={STYLES_PROFILE_IMAGE}
|
||||
src={owner.data.photo}
|
||||
alt={`${owner.username} profile`}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
);
|
||||
}
|
39
components/core/ObjectPreview/PdfObjectPreview.js
Normal file
39
components/core/ObjectPreview/PdfObjectPreview.js
Normal file
@ -0,0 +1,39 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import PdfPlaceholder from "./placeholders/PDF";
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 27%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function PDFObjectPreview(props) {
|
||||
return (
|
||||
<ObjectPreviewPremitive {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<PdfPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>PDF</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
76
components/core/ObjectPreview/TextObjectPreview.js
Normal file
76
components/core/ObjectPreview/TextObjectPreview.js
Normal file
@ -0,0 +1,76 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import TextPlaceholder from "./placeholders/Text";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 26%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const STYLES_TEXT_PREVIEW = (theme) =>
|
||||
css({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
margin: "8px",
|
||||
backgroundColor: "#FFF",
|
||||
borderRadius: "8px",
|
||||
boxShadow: theme.shadow.large,
|
||||
padding: "16px",
|
||||
});
|
||||
|
||||
export default function TextObjectPreview({ url, file, ...props }) {
|
||||
const [{ content, error }, setState] = React.useState({ content: "", error: undefined });
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
fetch(url)
|
||||
.then(async (res) => {
|
||||
const content = await res.text();
|
||||
setState({ content });
|
||||
})
|
||||
.catch((e) => {
|
||||
setState({ error: e });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tag = Utilities.getFileExtension(file.filename) || "text";
|
||||
|
||||
return (
|
||||
<ObjectPreviewPremitive tag={!error && tag} file={file} {...props}>
|
||||
<div css={[STYLES_CONTAINER, error && Styles.CONTAINER_CENTERED]}>
|
||||
{error ? (
|
||||
<>
|
||||
<TextPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>{tag}</P3>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div css={STYLES_TEXT_PREVIEW}>
|
||||
<P3>{content}</P3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
40
components/core/ObjectPreview/VideoObjectPreview.js
Normal file
40
components/core/ObjectPreview/VideoObjectPreview.js
Normal file
@ -0,0 +1,40 @@
|
||||
import "isomorphic-fetch";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { P3 } from "~/components/system";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import ObjectPreviewPremitive from "./ObjectPreviewPremitive";
|
||||
import VideoPlaceholder from "./placeholders/Video";
|
||||
|
||||
const STYLES_CONTAINER = css`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
bottom: 31.5%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default function VideoObjectPreview({ file, ...props }) {
|
||||
const { type } = file.data;
|
||||
const tag = type.split("/")[1];
|
||||
return (
|
||||
<ObjectPreviewPremitive file={file} {...props}>
|
||||
<div css={[Styles.CONTAINER_CENTERED, STYLES_CONTAINER]}>
|
||||
<VideoPlaceholder />
|
||||
<div css={STYLES_TAG}>
|
||||
<P3>{tag}</P3>
|
||||
</div>
|
||||
</div>
|
||||
</ObjectPreviewPremitive>
|
||||
);
|
||||
}
|
72
components/core/ObjectPreview/components/LikeButton.jsx
Normal file
72
components/core/ObjectPreview/components/LikeButton.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { motion, useAnimation } from "framer-motion";
|
||||
|
||||
const STYLES_BUTTON_HOVER = (theme) => css`
|
||||
display: flex;
|
||||
:hover path {
|
||||
stroke: ${theme.system.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const animate = async (controls) => {
|
||||
await controls.start({ scale: 1.3, rotateY: 180, fill: "rgba(0, 132, 255, 1)" });
|
||||
await controls.start({ scale: 1 });
|
||||
controls.set({ rotateY: 0 });
|
||||
};
|
||||
|
||||
const useMounted = (callback, depedencies) => {
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useLayoutEffect(() => {
|
||||
if (mountedRef.current && callback) {
|
||||
callback();
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, depedencies);
|
||||
};
|
||||
|
||||
export default function LikeButton({ onClick, isLiked, ...props }) {
|
||||
const controls = useAnimation();
|
||||
|
||||
useMounted(() => {
|
||||
if (isLiked) {
|
||||
animate(controls);
|
||||
return;
|
||||
}
|
||||
controls.start({ fill: "#fff", scale: 1 });
|
||||
}, [isLiked]);
|
||||
|
||||
return (
|
||||
<button
|
||||
css={[Styles.BUTTON_RESET, STYLES_BUTTON_HOVER]}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onClick) onClick();
|
||||
}}
|
||||
>
|
||||
<motion.svg
|
||||
width={20}
|
||||
height={20}
|
||||
initial={{ fill: isLiked ? "rgba(0, 132, 255, 1)" : "rgba(0, 132, 255, 0)" }}
|
||||
animate={controls}
|
||||
transition={{ duration: 0.3 }}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="M17.367 4.342a4.584 4.584 0 00-6.484 0L10 5.225l-.883-.883a4.584 4.584 0 00-6.484 6.483l.884.883L10 18.192l6.483-6.484.884-.883a4.584 4.584 0 000-6.483v0z"
|
||||
animate={{ stroke: isLiked ? Constants.system.blue : Constants.semantic.textGrayDark }}
|
||||
whileHover={{ stroke: Constants.system.blue }}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
</button>
|
||||
);
|
||||
}
|
81
components/core/ObjectPreview/components/SaveButton.jsx
Normal file
81
components/core/ObjectPreview/components/SaveButton.jsx
Normal file
@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Constants from "~/common/constants";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const STYLES_BUTTON_HOVER = (theme) => css`
|
||||
display: flex;
|
||||
:hover .button_path {
|
||||
stroke: ${theme.system.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
export default function SaveButton({ onSave, isSaved, ...props }) {
|
||||
return (
|
||||
<button
|
||||
css={[Styles.BUTTON_RESET, STYLES_BUTTON_HOVER]}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onSave) onSave();
|
||||
}}
|
||||
>
|
||||
<motion.svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 20 21"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
className="button_path"
|
||||
d="M18.3333 16.3333C18.3333 16.7754 18.1577 17.1993 17.8452 17.5118C17.5326 17.8244 17.1087 18 16.6667 18H3.33332C2.8913 18 2.46737 17.8244 2.15481 17.5118C1.84225 17.1993 1.66666 16.7754 1.66666 16.3333V4.66667C1.66666 4.22464 1.84225 3.80072 2.15481 3.48816C2.46737 3.17559 2.8913 3 3.33332 3H7.49999L9.16666 5.5H16.6667C17.1087 5.5 17.5326 5.67559 17.8452 5.98816C18.1577 6.30072 18.3333 6.72464 18.3333 7.16667V16.3333Z"
|
||||
stroke={Constants.semantic.textGrayDark}
|
||||
animate={{
|
||||
fill: isSaved ? "rgba(0, 132, 255, 1)" : "rgba(0, 132, 255, 0)",
|
||||
stroke: isSaved ? Constants.system.blue : Constants.system.black,
|
||||
}}
|
||||
strokeWidth={1.25}
|
||||
whileHover={{ stroke: Constants.system.blue }}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<motion.path
|
||||
initial={{ pathLength: isSaved ? 1 : 0 }}
|
||||
animate={{
|
||||
pathLength: isSaved ? 1 : 0,
|
||||
stroke: "#fff",
|
||||
}}
|
||||
style={{ y: 1, x: -0.3 }}
|
||||
d="M13 9l-3.438 3.438L8 10.874"
|
||||
strokeDashoffset="-1px"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transition={{ delay: isSaved ? 0.1 : 0 }}
|
||||
/>
|
||||
<motion.path
|
||||
className="button_path"
|
||||
animate={{ y: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
|
||||
d="M10 9.66665V14.6666"
|
||||
stroke="#48484A"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<motion.path
|
||||
className="button_path"
|
||||
animate={{ x: isSaved ? 2 : 0, opacity: isSaved ? 0 : 1 }}
|
||||
d="M7.5 12.1666H12.5"
|
||||
stroke="#48484A"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
</button>
|
||||
);
|
||||
}
|
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";
|
67
components/core/ObjectPreview/index.js
Normal file
67
components/core/ObjectPreview/index.js
Normal file
@ -0,0 +1,67 @@
|
||||
import * as React from "react";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import ImageObjectPreview from "./ImageObjectPreview";
|
||||
import VideoObjectPreview from "./VideoObjectPreview";
|
||||
import TextObjectPreview from "./TextObjectPreview";
|
||||
import PdfObjectPreview from "./PdfObjectPreview";
|
||||
import EpubObjectPreview from "./EpubObjectPreview";
|
||||
import AudioObjectPreview from "./AudioObjectPreview";
|
||||
import KeynoteObjectPreview from "./KeynoteObjectPreview";
|
||||
import DefaultObjectPreview from "./DefaultObjectPreview";
|
||||
import Object3DPreview from "./3dObjectPreview";
|
||||
import CodeObjectPreview from "./CodeObjectPreview";
|
||||
import FontObjectPreview from "./FontObjectPreview";
|
||||
|
||||
const ObjectPreview = ({ file, ...props }) => {
|
||||
const { type, coverImage } = file.data;
|
||||
|
||||
const url = Validations.isPreviewableImage(type)
|
||||
? Strings.getURLfromCID(file.cid)
|
||||
: Strings.getURLfromCID(coverImage?.cid);
|
||||
|
||||
if (Validations.isPreviewableImage(type)) {
|
||||
return <ImageObjectPreview file={file} url={url} {...props} />;
|
||||
}
|
||||
|
||||
if (type.startsWith("video/")) {
|
||||
return <VideoObjectPreview file={file} url={url} {...props} />;
|
||||
}
|
||||
|
||||
if (Validations.isPdfType(type)) {
|
||||
return <PdfObjectPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
if (type.startsWith("audio/")) {
|
||||
return <AudioObjectPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
if (type === "application/epub+zip") {
|
||||
return <EpubObjectPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
if (file.filename.endsWith(".key")) {
|
||||
return <KeynoteObjectPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
if (Validations.isCodeFile(file.filename)) {
|
||||
return <CodeObjectPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
if (Validations.isFontFile(file.filename)) {
|
||||
return <FontObjectPreview file={file} url={url} {...props} />;
|
||||
}
|
||||
|
||||
if (Validations.isMarkdown(file.filename, type)) {
|
||||
return <TextObjectPreview file={file} url={url} {...props} />;
|
||||
}
|
||||
|
||||
if (Validations.is3dFile(file.filename)) {
|
||||
return <Object3DPreview file={file} {...props} />;
|
||||
}
|
||||
|
||||
return <DefaultObjectPreview file={file} {...props} />;
|
||||
};
|
||||
|
||||
export default React.memo(ObjectPreview);
|
175
components/core/ObjectPreview/placeholders/3D.js
Normal file
175
components/core/ObjectPreview/placeholders/3D.js
Normal file
@ -0,0 +1,175 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
export default function Object3DPlaceholder({ ratio = 1, ...props }) {
|
||||
const STYLES_PLACEHOLDER = React.useMemo(
|
||||
() => css`
|
||||
overflow: visible !important;
|
||||
width: ${(69 / 248) * 100 * ratio}%;
|
||||
height: ${(76.65 / 248) * 100 * ratio}%;
|
||||
`,
|
||||
[ratio]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={69}
|
||||
height={76.65}
|
||||
viewBox="50 37 69 76.65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
css={STYLES_PLACEHOLDER}
|
||||
{...props}
|
||||
>
|
||||
<g filter="url(#prefix__filter0_d_3d)">
|
||||
<path
|
||||
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
|
||||
fill="url(#prefix__paint0_linear_3d)"
|
||||
/>
|
||||
<path
|
||||
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
|
||||
stroke="url(#prefix__paint1_linear_3d)"
|
||||
strokeWidth={0.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M50.535 55.68L84 75.038l33.465-19.358"
|
||||
stroke="url(#prefix__paint2_linear_3d)"
|
||||
strokeWidth={0.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M84 113.64V75"
|
||||
stroke="url(#prefix__paint3_linear_3d)"
|
||||
strokeWidth={0.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<mask id="prefix__a" maskUnits="userSpaceOnUse" x={49} y={36} width={70} height={78}>
|
||||
<path
|
||||
d="M118.5 90.333V59.667a7.661 7.661 0 00-3.833-6.632L87.833 37.702a7.666 7.666 0 00-7.666 0L53.333 53.035a7.667 7.667 0 00-3.833 6.632v30.666a7.667 7.667 0 003.833 6.632l26.834 15.333a7.666 7.666 0 007.666 0l26.834-15.333a7.67 7.67 0 003.833-6.632z"
|
||||
fill="url(#prefix__paint4_linear_3d)"
|
||||
stroke="url(#prefix__paint5_linear_3d)"
|
||||
strokeWidth={0.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#prefix__a)">
|
||||
<path d="M84 74.609L121 53v42.137L84 117V74.609z" fill="url(#prefix__paint6_linear_3d)" />
|
||||
<path d="M84 74.602L48 53.33v41.148L84 116V74.602z" fill="url(#prefix__paint7_linear_3d)" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="prefix__paint0_linear_3d"
|
||||
x1={84}
|
||||
y1={36.675}
|
||||
x2={84}
|
||||
y2={113.326}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset={0.396} stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#C7C7CC" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint1_linear_3d"
|
||||
x1={84}
|
||||
y1={36.675}
|
||||
x2={84}
|
||||
y2={113.326}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset={0.005} stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#D1D1D6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint2_linear_3d"
|
||||
x1={84}
|
||||
y1={51}
|
||||
x2={84}
|
||||
y2={75.038}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F2F2F7" />
|
||||
<stop offset={1} stopColor="#F2F2F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint3_linear_3d"
|
||||
x1={84.5}
|
||||
y1={75}
|
||||
x2={84.5}
|
||||
y2={113.64}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F2F2F7" />
|
||||
<stop offset={1} stopColor="#fff" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint4_linear_3d"
|
||||
x1={84}
|
||||
y1={36.675}
|
||||
x2={84}
|
||||
y2={113.326}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset={0.396} stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#C7C7CC" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint5_linear_3d"
|
||||
x1={84}
|
||||
y1={36.675}
|
||||
x2={84}
|
||||
y2={113.326}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset={0.005} stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#D1D1D6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint6_linear_3d"
|
||||
x1={99.014}
|
||||
y1={66.505}
|
||||
x2={119.08}
|
||||
y2={102.033}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#C7C7CC" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="prefix__paint7_linear_3d"
|
||||
x1={81.913}
|
||||
y1={72.676}
|
||||
x2={65.234}
|
||||
y2={106.46}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#C7C7CC" />
|
||||
</linearGradient>
|
||||
<filter
|
||||
id="prefix__filter0_d_3d"
|
||||
x={1.25}
|
||||
y={0.425}
|
||||
width={165.5}
|
||||
height={173.151}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dy={12} />
|
||||
<feGaussianBlur stdDeviation={24} />
|
||||
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
57
components/core/ObjectPreview/placeholders/Audio.js
Normal file
57
components/core/ObjectPreview/placeholders/Audio.js
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
export default function AudioPlaceholder({ ratio = 1, ...props }) {
|
||||
const STYLES_PLACEHOLDER = React.useMemo(
|
||||
() => css`
|
||||
overflow: visible !important;
|
||||
width: ${(163 / 248) * 100 * ratio}%;
|
||||
height: ${(163 / 248) * 100 * ratio}%;
|
||||
`,
|
||||
[ratio]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={163}
|
||||
height={163}
|
||||
viewBox="0 0 163 163"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
css={STYLES_PLACEHOLDER}
|
||||
{...props}
|
||||
>
|
||||
<circle cx={81.5} cy={81.5} r={81.5} fill="url(#prefix__paint0_radial)" />
|
||||
<path
|
||||
d="M82 95.333c7.364 0 13.333-5.97 13.333-13.333 0-7.364-5.97-13.333-13.333-13.333-7.364 0-13.333 5.97-13.333 13.333 0 7.364 5.97 13.333 13.333 13.333z"
|
||||
fill="#fff"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M79.333 76.667l8 5.333-8 5.333V76.667z"
|
||||
fill="#C7C7CC"
|
||||
stroke="#C7C7CC"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="prefix__paint0_radial"
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={1}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(90 0 81.5) scale(87.5)"
|
||||
>
|
||||
<stop stopColor="#C4C4C4" />
|
||||
<stop offset={1} stopColor="#C4C4C4" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
64
components/core/ObjectPreview/placeholders/Code.js
Normal file
64
components/core/ObjectPreview/placeholders/Code.js
Normal file
File diff suppressed because one or more lines are too long
106
components/core/ObjectPreview/placeholders/EPUB.js
Normal file
106
components/core/ObjectPreview/placeholders/EPUB.js
Normal file
File diff suppressed because one or more lines are too long
68
components/core/ObjectPreview/placeholders/File.js
Normal file
68
components/core/ObjectPreview/placeholders/File.js
Normal file
@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
export default function FilePlaceholder({ ratio = 1, ...props }) {
|
||||
const STYLES_PLACEHOLDER = React.useMemo(
|
||||
() => css`
|
||||
overflow: visible !important;
|
||||
width: ${(121 / 248) * 100 * ratio}%;
|
||||
height: ${(151 / 248) * 100 * ratio}%;
|
||||
`,
|
||||
[ratio]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={121}
|
||||
height={151}
|
||||
viewBox="0 4 121 151"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
css={STYLES_PLACEHOLDER}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 157h105a8 8 0 008-8V42.314a8 8 0 00-2.343-5.657L90.343 8.343A8 8 0 0084.686 6H8a8 8 0 00-8 8v135a8 8 0 008 8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M73 83.333V72.667a2.667 2.667 0 00-1.333-2.307l-9.334-5.333a2.667 2.667 0 00-2.666 0l-9.334 5.333A2.668 2.668 0 0049 72.667v10.666a2.667 2.667 0 001.333 2.307l9.334 5.333a2.667 2.667 0 002.666 0l9.334-5.333A2.667 2.667 0 0073 83.333z"
|
||||
fill="#E5E5EA"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.36 71.28L61 78.013l11.64-6.733M61 91.44V78"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g filter="url(#prefix__filter0_d)">
|
||||
<path d="M98 37h21L90 8v21a8 8 0 008 8z" fill="#D1D1D6" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="prefix__filter0_d"
|
||||
x={74}
|
||||
y={0}
|
||||
width={69}
|
||||
height={69}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dx={4} dy={12} />
|
||||
<feGaussianBlur stdDeviation={10} />
|
||||
<feColorMatrix values="0 0 0 0 0.780392 0 0 0 0 0.780392 0 0 0 0 0.8 0 0 0 1 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
107
components/core/ObjectPreview/placeholders/Keynote.js
Normal file
107
components/core/ObjectPreview/placeholders/Keynote.js
Normal file
@ -0,0 +1,107 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
export default function KeynotePlaceholder({ ratio = 1, ...props }) {
|
||||
const STYLES_PLACEHOLDER = React.useMemo(
|
||||
() => css`
|
||||
overflow: visible !important;
|
||||
width: ${(183 / 248) * 100 * ratio}%;
|
||||
height: ${(115 / 248) * 100 * ratio}%;
|
||||
`,
|
||||
[ratio]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={183}
|
||||
height={115}
|
||||
viewBox="49 38 183 115"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
css={STYLES_PLACEHOLDER}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M67 157h143a8 8 0 008-8V78.13a8 8 0 00-1.957-5.242l-24.401-28.13A8.002 8.002 0 00185.598 42H67a8 8 0 00-8 8v99a8 8 0 008 8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<g filter="url(#prefix__filter0_d_keynote)">
|
||||
<path
|
||||
d="M60 154h157a8 8 0 008-8V75.323a8.002 8.002 0 00-2.182-5.49l-26.732-28.324A8.001 8.001 0 00190.268 39H60a8 8 0 00-8 8v99a8 8 0 008 8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#prefix__filter1_d_keynote)">
|
||||
<path
|
||||
d="M56 150.5h166.5a8 8 0 008-8V72.814a8 8 0 00-2.343-5.657l-28.814-28.814A8 8 0 00193.686 36H56a8 8 0 00-8 8v98.5a8 8 0 008 8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#prefix__filter2_d_keynote)">
|
||||
<path d="M207 68h22l-30-30v22a8 8 0 008 8z" fill="#D1D1D6" />
|
||||
</g>
|
||||
<path
|
||||
d="M119.667 87h-9.334c-.736 0-1.333.597-1.333 1.333v9.334c0 .736.597 1.333 1.333 1.333h9.334c.736 0 1.333-.597 1.333-1.333v-9.334c0-.736-.597-1.333-1.333-1.333zM137.86 87.573L132.213 97a1.33 1.33 0 00-.003 1.327 1.328 1.328 0 001.143.673h11.294a1.33 1.33 0 001.318-1.337 1.33 1.33 0 00-.178-.663l-5.647-9.427a1.332 1.332 0 00-2.28 0zM163 99.667a6.667 6.667 0 100-13.334 6.667 6.667 0 000 13.334z"
|
||||
fill="#E5E5EA"
|
||||
stroke="#E5E5EA"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="prefix__filter0_d_keynote"
|
||||
x={4}
|
||||
y={3}
|
||||
width={269}
|
||||
height={211}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dy={12} />
|
||||
<feGaussianBlur stdDeviation={24} />
|
||||
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
<filter
|
||||
id="prefix__filter1_d_keynote"
|
||||
x={0}
|
||||
y={0}
|
||||
width={278.5}
|
||||
height={210.5}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dy={12} />
|
||||
<feGaussianBlur stdDeviation={24} />
|
||||
<feColorMatrix values="0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0 0.698039 0 0 0 0.3 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
<filter
|
||||
id="prefix__filter2_d_keynote"
|
||||
x={183}
|
||||
y={30}
|
||||
width={70}
|
||||
height={70}
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity={0} result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
|
||||
<feOffset dx={4} dy={12} />
|
||||
<feGaussianBlur stdDeviation={10} />
|
||||
<feColorMatrix values="0 0 0 0 0.780392 0 0 0 0 0.780392 0 0 0 0 0.8 0 0 0 1 0" />
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
89
components/core/ObjectPreview/placeholders/PDF.js
Normal file
89
components/core/ObjectPreview/placeholders/PDF.js
Normal file
File diff suppressed because one or more lines are too long
90
components/core/ObjectPreview/placeholders/Text.js
Normal file
90
components/core/ObjectPreview/placeholders/Text.js
Normal file
File diff suppressed because one or more lines are too long
56
components/core/ObjectPreview/placeholders/Video.js
Normal file
56
components/core/ObjectPreview/placeholders/Video.js
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
export default function VideoPlaceholder({ ratio = 1, ...props }) {
|
||||
const STYLES_PLACEHOLDER = React.useMemo(
|
||||
() => css`
|
||||
overflow: visible !important;
|
||||
width: ${(188 / 248) * 100 * ratio}%;
|
||||
height: ${(125 / 248) * 100 * ratio}%;
|
||||
`,
|
||||
[ratio]
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={188}
|
||||
height={125}
|
||||
viewBox="0 0 188 125"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
css={STYLES_PLACEHOLDER}
|
||||
{...props}
|
||||
>
|
||||
<rect width={188} height={125} rx={8} fill="url(#prefix__paint0_linear)" />
|
||||
<path
|
||||
d="M94 73.333c7.364 0 13.333-5.97 13.333-13.333 0-7.364-5.969-13.333-13.333-13.333S80.667 52.637 80.667 60c0 7.364 5.97 13.333 13.333 13.333z"
|
||||
fill="#fff"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M91.333 54.667l8 5.333-8 5.333V54.667z"
|
||||
fill="#C7C7CC"
|
||||
stroke="#C7C7CC"
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="prefix__paint0_linear"
|
||||
x1={182}
|
||||
y1={99}
|
||||
x2={0}
|
||||
y2={100}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#fff" />
|
||||
<stop offset={1} stopColor="#F2F2F7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
110
components/core/ObjectPreview/placeholders/index.js
Normal file
110
components/core/ObjectPreview/placeholders/index.js
Normal file
@ -0,0 +1,110 @@
|
||||
import * as React from "react";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
import * as Typography from "~/components/system/components/Typography";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import PdfPlaceholder from "./PDF";
|
||||
import AudioPlaceholder from "./Audio";
|
||||
import CodePlaceholder from "./Code";
|
||||
import EpubPlaceholder from "./EPUB";
|
||||
import TextPlaceholder from "./Text";
|
||||
import KeynotePlaceholder from "./Keynote";
|
||||
import Object3DPlaceholder from "./3D";
|
||||
import FilePlaceholder from "./File";
|
||||
import VideoPlaceholder from "./Video";
|
||||
|
||||
const STYLES_PLACEHOLDER_CONTAINER = (theme) => css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
width: 86px;
|
||||
min-width: 64px;
|
||||
border-radius: 4px;
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
`;
|
||||
|
||||
const STYLES_TAG = (theme) => css`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 8px;
|
||||
transform: translateX(-50%);
|
||||
text-transform: uppercase;
|
||||
border: 1px solid ${theme.system.grayLight5};
|
||||
background-color: ${theme.semantic.bgLight};
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const PlaceholderPremitive = ({ file, ratio }) => {
|
||||
const { type } = file.data;
|
||||
|
||||
if (type.startsWith("video/")) {
|
||||
return <VideoPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (Validations.isPdfType(type)) {
|
||||
return <PdfPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (type.startsWith("audio/")) {
|
||||
return <AudioPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (type === "application/epub+zip") {
|
||||
return <EpubPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (file.filename.endsWith(".key")) {
|
||||
return <KeynotePlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (Validations.isCodeFile(file.filename)) {
|
||||
return <CodePlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (Validations.isMarkdown(file.filename, type)) {
|
||||
return <TextPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
if (Validations.is3dFile(file.filename)) {
|
||||
return <Object3DPlaceholder ratio={ratio} />;
|
||||
}
|
||||
|
||||
return <FilePlaceholder ratio={ratio} />;
|
||||
};
|
||||
|
||||
export default function Placeholder({ file, containerCss, ratio, showTag }) {
|
||||
const { type } = file.data;
|
||||
|
||||
const tag = React.useMemo(() => {
|
||||
if (!showTag) return false;
|
||||
if (type.startsWith("video/")) return type.split("/")[1];
|
||||
if (Validations.isPdfType(type)) return "pdf";
|
||||
if (type.startsWith("audio/")) return Utilities.getFileExtension(file.filename) || "audio";
|
||||
if (type === "application/epub+zip") return "epub";
|
||||
if (file.filename.endsWith(".key")) return "keynote";
|
||||
if (Validations.isCodeFile(file.filename))
|
||||
return Utilities.getFileExtension(file.filename) || "code";
|
||||
if (Validations.isFontFile(file.filename))
|
||||
return Utilities.getFileExtension(file.filename) || "font";
|
||||
if (Validations.isMarkdown(file.filename, type))
|
||||
return Utilities.getFileExtension(file.filename) || "text";
|
||||
if (Validations.is3dFile(file.filename)) return "3d";
|
||||
return "file";
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div css={[STYLES_PLACEHOLDER_CONTAINER, containerCss]}>
|
||||
{showTag && (
|
||||
<div css={STYLES_TAG}>
|
||||
<Typography.P3>{tag}</Typography.P3>
|
||||
</div>
|
||||
)}
|
||||
<PlaceholderPremitive ratio={ratio} file={file} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -6,6 +6,7 @@ import * as Actions from "~/common/actions";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as Window from "~/common/window";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "~/components/core/Link";
|
||||
@ -20,9 +21,9 @@ import { LoaderSpinner } from "~/components/system/components/Loaders";
|
||||
|
||||
import ProcessedText from "~/components/core/ProcessedText";
|
||||
import SlatePreviewBlocks from "~/components/core/SlatePreviewBlock";
|
||||
import CTATransition from "~/components/core/CTATransition";
|
||||
import DataView from "~/components/core/DataView";
|
||||
import EmptyState from "~/components/core/EmptyState";
|
||||
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
|
||||
|
||||
const STYLES_PROFILE_BACKGROUND = css`
|
||||
background-color: ${Constants.system.white};
|
||||
@ -265,6 +266,7 @@ function UserEntry({ user, button, onClick, message, checkStatus }) {
|
||||
|
||||
function FilesPage({
|
||||
library,
|
||||
user,
|
||||
isOwner,
|
||||
isMobile,
|
||||
viewer,
|
||||
@ -295,6 +297,7 @@ function FilesPage({
|
||||
{library.length ? (
|
||||
<DataView
|
||||
key="scene-profile"
|
||||
user={user}
|
||||
onAction={onAction}
|
||||
viewer={viewer}
|
||||
isOwner={isOwner}
|
||||
@ -345,7 +348,18 @@ function CollectionsPage({
|
||||
style={{ margin: "0 0 24px 0" }}
|
||||
/>
|
||||
{slates?.length ? (
|
||||
<SlatePreviewBlocks external={!viewer} slates={slates || []} onAction={onAction} />
|
||||
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
|
||||
{slates.map((collection) => (
|
||||
<Link key={collection.id} href={`/$/slate/${collection.id}`} onAction={onAction}>
|
||||
<CollectionPreviewBlock
|
||||
onAction={onAction}
|
||||
collection={collection}
|
||||
viewer={viewer}
|
||||
owner={user}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState>
|
||||
{tab === "collections" || fetched ? (
|
||||
@ -436,7 +450,7 @@ function PeersPage({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Link href={`/$/user/${relation.id}`} onAction={onAction}>
|
||||
<Link key={relation.id} href={`/$/user/${relation.id}`} onAction={onAction}>
|
||||
<UserEntry key={relation.id} user={relation} button={button} checkStatus={checkStatus} />
|
||||
</Link>
|
||||
);
|
||||
@ -664,7 +678,9 @@ export default class Profile extends React.Component {
|
||||
style={{ marginTop: 0, marginBottom: 32 }}
|
||||
itemStyle={{ margin: "0px 16px" }}
|
||||
/>
|
||||
{subtab === "files" ? <FilesPage {...this.props} library={library} tab={tab} /> : null}
|
||||
{subtab === "files" ? (
|
||||
<FilesPage {...this.props} user={user} library={library} tab={tab} />
|
||||
) : null}
|
||||
{subtab === "collections" ? (
|
||||
<CollectionsPage
|
||||
{...this.props}
|
||||
|
231
components/core/ProfilePreviewBlock.js
Normal file
231
components/core/ProfilePreviewBlock.js
Normal file
@ -0,0 +1,231 @@
|
||||
import * as React from "react";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Typography from "~/components/system/components/Typography";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import { Divider } from "~/components/system/components/Divider";
|
||||
import { Logo } from "~/common/logo";
|
||||
import { ButtonPrimary, ButtonTertiary } from "~/components/system/components/Buttons";
|
||||
import { css } from "@emotion/react";
|
||||
import { LikeButton, SaveButton } from "~/components/core/ObjectPreview/components";
|
||||
import { useLikeHandler, useSaveHandler } from "~/common/hooks";
|
||||
import { useFollowProfileHandler } from "~/common/hooks";
|
||||
|
||||
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
|
||||
|
||||
const STYLES_CONTROLLS = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const useProfileCarrousel = ({ objects }) => {
|
||||
const [selectedIdx, setSelectedIdx] = React.useState(0);
|
||||
const selectBatchIdx = (idx) => setSelectedIdx(idx);
|
||||
const selectedBatch = objects[selectedIdx];
|
||||
return { selectBatchIdx, selectedBatch, selectedIdx };
|
||||
};
|
||||
|
||||
const STYLES_HIGHLIGHT_BUTTON = (theme) => css`
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
padding: 4px 16px 4px 12px;
|
||||
border: none;
|
||||
background-color: unset;
|
||||
div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${theme.system.gray};
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_PLACEHOLDER = css`
|
||||
height: 64px;
|
||||
min-width: 86px;
|
||||
width: 86px;
|
||||
`;
|
||||
|
||||
const ProfilePreviewFile = ({ file, viewer }) => {
|
||||
const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
|
||||
const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
|
||||
|
||||
const title = file.data.name || file.filename;
|
||||
const { body } = file.data;
|
||||
return (
|
||||
<div css={[Styles.HORIZONTAL_CONTAINER]}>
|
||||
<ObjectPlaceholder ratio={1.1} file={file} containerCss={STYLES_PLACEHOLDER} showTag />
|
||||
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
|
||||
<Typography.H5 color="textBlack" nbrOflines={1}>
|
||||
{title}
|
||||
</Typography.H5>
|
||||
<Typography.P3 nbrOflines={1} color="textGrayDark">
|
||||
{body}
|
||||
</Typography.P3>
|
||||
<div style={{ marginTop: "auto" }} css={Styles.HORIZONTAL_CONTAINER}>
|
||||
<div css={Styles.CONTAINER_CENTERED}>
|
||||
<LikeButton isLiked={isLiked} onClick={like} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{likeCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
<div style={{ marginLeft: 48 }} css={Styles.CONTAINER_CENTERED}>
|
||||
<SaveButton onSave={save} isSaved={isSaved} />
|
||||
<Typography.P1 style={{ marginLeft: 8 }} color="textGrayDark">
|
||||
{saveCount}
|
||||
</Typography.P1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const STYLES_CONTAINER = (theme) => css`
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px ${theme.semantic.bgGrayLight};
|
||||
background-color: ${theme.semantic.bgGrayLight};
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE_DESCRIPTION = (theme) => css`
|
||||
background-color: ${theme.system.white};
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const STYLES_PROFILE_PREVIEW = (theme) => css`
|
||||
height: 120px;
|
||||
width: 120px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
@media (max-width: ${theme.sizes.mobile}px) {
|
||||
height: 104px;
|
||||
width: 104px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_FILES_PREVIEWS = css`
|
||||
padding: 16px;
|
||||
padding-right: 0px;
|
||||
height: 176px;
|
||||
`;
|
||||
|
||||
export default function ProfilePreviewBlock({ onAction, viewer, profile }) {
|
||||
const filePreviews = React.useMemo(() => {
|
||||
const files = profile?.objects || [];
|
||||
let previews = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const batch = [];
|
||||
if (files[i * 2]) batch.push(files[i * 2]);
|
||||
if (files[i * 2 + 1]) batch.push(files[i * 2 + 1]);
|
||||
if (batch.length > 0) previews.push(batch);
|
||||
if (previews.length === 3 || batch.length < 2) break;
|
||||
}
|
||||
return previews;
|
||||
}, [profile]);
|
||||
|
||||
const { selectBatchIdx, selectedBatch, selectedIdx } = useProfileCarrousel({
|
||||
objects: filePreviews,
|
||||
});
|
||||
|
||||
const { handleFollow, isFollowing } = useFollowProfileHandler({
|
||||
onAction,
|
||||
viewer,
|
||||
user: profile,
|
||||
});
|
||||
|
||||
const isOwner = viewer?.id === profile.id;
|
||||
|
||||
const nbrOfFiles = profile?.objects?.length || 0;
|
||||
const doesProfileHaveFiles = nbrOfFiles === 0;
|
||||
|
||||
return (
|
||||
<div css={STYLES_CONTAINER}>
|
||||
<div css={[STYLES_PROFILE_DESCRIPTION, Styles.HORIZONTAL_CONTAINER]}>
|
||||
<img css={STYLES_PROFILE_PREVIEW} src={profile.data.photo} alt={`${profile.username}`} />
|
||||
<div style={{ marginLeft: 16 }} css={Styles.VERTICAL_CONTAINER}>
|
||||
<div>
|
||||
<Typography.H4>{profile.username}</Typography.H4>
|
||||
{profile?.data?.body && (
|
||||
<Typography.P2 color="gray" style={{ marginTop: 2 }}>
|
||||
{profile.data.body}
|
||||
</Typography.P2>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }} css={Styles.HORIZONTAL_CONTAINER}>
|
||||
<Typography.H5>
|
||||
{profile.fileCount} {Strings.pluralize("file", profile.fileCount)}
|
||||
</Typography.H5>
|
||||
<Typography.H5 style={{ marginLeft: 16 }}>
|
||||
{profile.slateCount} {Strings.pluralize("collection", profile.slateCount)}
|
||||
</Typography.H5>
|
||||
</div>
|
||||
|
||||
{!isOwner &&
|
||||
(isFollowing ? (
|
||||
<ButtonTertiary
|
||||
style={{ marginTop: "auto", maxWidth: "91px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleFollow(profile.id);
|
||||
}}
|
||||
>
|
||||
Following
|
||||
</ButtonTertiary>
|
||||
) : (
|
||||
<ButtonPrimary
|
||||
style={{ marginTop: "auto", maxWidth: "91px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleFollow(profile.id);
|
||||
}}
|
||||
>
|
||||
Follow
|
||||
</ButtonPrimary>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div css={STYLES_FILES_PREVIEWS} style={{ display: "flex" }}>
|
||||
<div style={{ width: "100%" }}>
|
||||
{!doesProfileHaveFiles ? (
|
||||
selectedBatch.map((file, i) => (
|
||||
<React.Fragment key={file.id}>
|
||||
{i === 1 && <Divider color="grayLight4" style={{ margin: "8px 0px" }} />}
|
||||
<ProfilePreviewFile file={file} viewer={viewer} />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
css={[Styles.CONTAINER_CENTERED, Styles.VERTICAL_CONTAINER]}
|
||||
>
|
||||
<Logo style={{ height: 18, marginBottom: 8 }} />
|
||||
<Typography.P1 color="textGrayDark">No files in this collection</Typography.P1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
<div css={STYLES_CONTROLLS}>
|
||||
{filePreviews.map((preview, i) => (
|
||||
<button
|
||||
key={i}
|
||||
css={[Styles.HOVERABLE, STYLES_HIGHLIGHT_BUTTON]}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
selectBatchIdx(i);
|
||||
}}
|
||||
aria-label="Next Preview Image"
|
||||
>
|
||||
<div style={{ opacity: i === selectedIdx ? 1 : 0.3 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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}>
|
||||
|
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;
|
||||
|
@ -5,6 +5,60 @@ import * as Strings from "~/common/strings";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
const LINK_STYLES = `
|
||||
font-family: ${Constants.font.text};
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
color: ${Constants.system.grayLight2};
|
||||
cursor: pointer;
|
||||
transition: 200ms ease color;
|
||||
:hover {
|
||||
color: ${Constants.system.grayDark6};
|
||||
}
|
||||
`;
|
||||
|
||||
const useColorProp = (color) =>
|
||||
React.useMemo(
|
||||
() => (theme) => {
|
||||
if (!color) return;
|
||||
if (!(color in theme.system) && !(color in theme.semantic)) {
|
||||
console.warn(`${color} doesn't exist in our design system`);
|
||||
return;
|
||||
}
|
||||
return css({ color: theme.system[color] || theme.semantic[color] });
|
||||
},
|
||||
[color]
|
||||
);
|
||||
|
||||
const truncateElements = (nbrOfLines) =>
|
||||
nbrOfLines &&
|
||||
css`
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: ${nbrOfLines};
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
`;
|
||||
|
||||
const STYLES_LINK = css`
|
||||
${LINK_STYLES}
|
||||
`;
|
||||
|
||||
const STYLES_LINK_DARK = css`
|
||||
color: ${Constants.system.grayLight2};
|
||||
:hover {
|
||||
color: ${Constants.system.white};
|
||||
}
|
||||
`;
|
||||
|
||||
const ANCHOR = `
|
||||
a {
|
||||
${LINK_STYLES}
|
||||
}
|
||||
`;
|
||||
|
||||
const onDeepLink = async (object) => {
|
||||
let slug = object.deeplink
|
||||
.split("/")
|
||||
@ -28,6 +82,7 @@ export const A = ({ href, children, dark }) => {
|
||||
rel: isExternal(href) ? "external nofollow" : "",
|
||||
css: Styles.LINK,
|
||||
children,
|
||||
// css: dark ? STYLES_LINK_DARK : STYLES_LINK,
|
||||
};
|
||||
|
||||
// process all types of Slate links
|
||||
@ -52,7 +107,7 @@ export const A = ({ href, children, dark }) => {
|
||||
default: {
|
||||
}
|
||||
}
|
||||
return <a {...linkProps} />;
|
||||
return <a {...linkProps}>{children}</a>;
|
||||
};
|
||||
|
||||
// const STYLES_H1 = css`
|
||||
@ -82,68 +137,124 @@ export const A = ({ href, children, dark }) => {
|
||||
// ${ANCHOR}
|
||||
// `;
|
||||
|
||||
export const H1 = (props) => {
|
||||
return <h1 {...props} css={[Styles.H1, props?.css]} />;
|
||||
export const H1 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<h1 {...props} css={[Styles.H1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export const H2 = (props) => {
|
||||
return <h2 {...props} css={[Styles.H2, props?.css]} />;
|
||||
export const H2 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<h2 {...props} css={[Styles.H2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
|
||||
export const H3 = (props) => {
|
||||
return <h3 {...props} css={[Styles.H3, props?.css]} />;
|
||||
export const H3 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<h3 {...props} css={[Styles.H3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export const H4 = (props) => {
|
||||
return <h4 {...props} css={[Styles.H4, props?.css]} />;
|
||||
export const H4 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<h4 {...props} css={[Styles.H4, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
};
|
||||
|
||||
export const H5 = (props) => {
|
||||
return <h5 {...props} css={[Styles.H5, props?.css]} />;
|
||||
export const H5 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<h5 {...props} css={[Styles.H5, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
};
|
||||
|
||||
// const STYLES_P = css`
|
||||
// box-sizing: border-box;
|
||||
// font-family: ${Constants.font.text};
|
||||
// font-size: ${Constants.typescale.lvl1};
|
||||
// line-height: 1.5;
|
||||
// overflow-wrap: break-word;
|
||||
|
||||
// strong,
|
||||
// b {
|
||||
// font-family: ${Constants.font.semiBold};
|
||||
// font-weight: 400;
|
||||
// }
|
||||
|
||||
// ${ANCHOR}
|
||||
// `;
|
||||
|
||||
export const P1 = (props) => {
|
||||
return <p {...props} css={[Styles.P1, props?.css]} />;
|
||||
export const P1 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.P1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const P2 = (props) => {
|
||||
return <p {...props} css={[Styles.P2, props?.css]} />;
|
||||
export const P2 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.P2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const P3 = (props) => {
|
||||
return <p {...props} css={[Styles.P3, props?.css]} />;
|
||||
export const P3 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.P3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const C1 = (props) => {
|
||||
return <p {...props} css={[Styles.C1, props?.css]} />;
|
||||
export const C1 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.C1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const C2 = (props) => {
|
||||
return <p {...props} css={[Styles.C2, props?.css]} />;
|
||||
export const C2 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.C2, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const C3 = (props) => {
|
||||
return <p {...props} css={[Styles.C3, props?.css]} />;
|
||||
export const C3 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.C3, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const B1 = (props) => {
|
||||
return <p {...props} css={[Styles.B1, props?.css]} />;
|
||||
export const B1 = ({ nbrOflines, children, color, ...props }) => {
|
||||
const TRUNCATE_STYLE = React.useMemo(() => truncateElements(nbrOflines), [nbrOflines]);
|
||||
const COLOR_STYLES = useColorProp(color);
|
||||
return (
|
||||
<p {...props} css={[Styles.B1, TRUNCATE_STYLE, COLOR_STYLES, props?.css]}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const STYLES_UL = css`
|
||||
@ -167,7 +278,6 @@ export const OL = (props) => {
|
||||
const STYLES_LI = css`
|
||||
box-sizing: border-box;
|
||||
margin-top: 12px;
|
||||
|
||||
strong {
|
||||
font-family: ${Constants.font.semiBold};
|
||||
font-weight: 400;
|
||||
|
@ -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,153 @@ export default async ({
|
||||
earliestTimestamp,
|
||||
latestTimestamp,
|
||||
}) => {
|
||||
let usersFollowing = following || [];
|
||||
if (!following?.length || following.length < 3) {
|
||||
usersFollowing.push(
|
||||
...[
|
||||
"f292c19f-1337-426c-8002-65e128b95096",
|
||||
"708559e6-cfc9-4b82-8241-3f4e5046028d",
|
||||
"1195e8bb-3f94-4b47-9deb-b30d6a6f82c4",
|
||||
"231d5d53-f341-448b-9e92-0b7847c5b667",
|
||||
"a280fce9-d85f-455a-9523-88f2bacd7d63",
|
||||
"d909728d-f699-4474-a7d4-584b62907c53",
|
||||
"20c15eab-ad33-40fd-a434-958a5f1ccb67",
|
||||
"3cad78ea-01ad-4c92-8983-a97524fb9e35",
|
||||
]
|
||||
);
|
||||
}
|
||||
// const slateFiles = DB.raw(
|
||||
// "coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??",
|
||||
// ["files", "slate_files.createdAt", "files.id", "objects"]
|
||||
// );
|
||||
const slateFilesFields = ["files", "slate_files.createdAt", "files.id", "objects"];
|
||||
const slateFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
|
||||
|
||||
const slateFields = [
|
||||
"slate_table",
|
||||
"slates.id",
|
||||
"slates.slatename",
|
||||
"slates.data",
|
||||
"slates.ownerId",
|
||||
"slates.isPublic",
|
||||
"slates.subscriberCount",
|
||||
"slates.fileCount",
|
||||
...slateFilesFields,
|
||||
"slates",
|
||||
"slate_files",
|
||||
"slate_files.slateId",
|
||||
"slates.id",
|
||||
"files",
|
||||
"files.id",
|
||||
"slate_files.fileId",
|
||||
"slates.id",
|
||||
];
|
||||
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
|
||||
|
||||
const userFilesFields = ["files", "files.createdAt", "files.id", "objects"];
|
||||
const userFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
|
||||
const userFields = [
|
||||
"user_table",
|
||||
"users.id",
|
||||
"users.createdAt",
|
||||
"users.username",
|
||||
"users.data",
|
||||
"users.followerCount",
|
||||
"users.fileCount",
|
||||
"users.slateCount",
|
||||
...userFilesFields,
|
||||
"users",
|
||||
"files",
|
||||
"users.id",
|
||||
"files.ownerId",
|
||||
"users.id",
|
||||
];
|
||||
const userQuery = `, ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${userFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
|
||||
|
||||
const fileFields = [
|
||||
"files_table",
|
||||
"files.*",
|
||||
...Constants.userPreviewProperties,
|
||||
"owner",
|
||||
"files",
|
||||
"users",
|
||||
"files.ownerId",
|
||||
"users.id",
|
||||
];
|
||||
const fileQuery = `, ?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? on ?? = ??)`;
|
||||
|
||||
const selectFields = [
|
||||
...slateFields,
|
||||
...userFields,
|
||||
...fileFields,
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"slate_table",
|
||||
"slate",
|
||||
"files_table",
|
||||
"file",
|
||||
"user_table",
|
||||
"user",
|
||||
"owners",
|
||||
"owner",
|
||||
"activity",
|
||||
"slate_table",
|
||||
"slate_table.id",
|
||||
"activity.slateId",
|
||||
"user_table",
|
||||
"user_table.id",
|
||||
"activity.userId",
|
||||
"files_table",
|
||||
"files_table.id",
|
||||
"activity.fileId",
|
||||
"user_table",
|
||||
"owners",
|
||||
"owners.id",
|
||||
"activity.ownerId",
|
||||
];
|
||||
const selectQuery = `${slateQuery} ${userQuery} ${fileQuery} SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??`;
|
||||
// const selectQuery =
|
||||
// "SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??";
|
||||
|
||||
const conditionFields = ["activity.ownerId", usersFollowing, "activity.slateId", subscriptions];
|
||||
return await runQuery({
|
||||
label: "GET_ACTIVITY_FOR_USER_ID",
|
||||
queryFn: async (DB) => {
|
||||
const users = () => DB.raw("row_to_json(??) as ??", ["users", "owner"]);
|
||||
|
||||
const slates = () => DB.raw("row_to_json(??) as ??", ["slates", "slate"]);
|
||||
|
||||
const files = () => DB.raw("row_to_json(??) as ??", ["files", "file"]);
|
||||
|
||||
let query;
|
||||
if (earliestTimestamp) {
|
||||
//NOTE(martina): for pagination, fetching the "next 100" results
|
||||
let date = new Date(earliestTimestamp);
|
||||
let s = date.getSeconds();
|
||||
if (s < 0) {
|
||||
s = 60 + s;
|
||||
}
|
||||
date.setSeconds(s - 1);
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.whereRaw("?? < ? and ?? = ? and (?? = any(?) or ?? = any(?))", [
|
||||
"activity.createdAt",
|
||||
date.toISOString(),
|
||||
"activity.type",
|
||||
"CREATE_SLATE_OBJECT",
|
||||
"activity.ownerId",
|
||||
following,
|
||||
"activity.slateId",
|
||||
subscriptions,
|
||||
])
|
||||
// .where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
// .where("activity.createdAt", "<", date.toISOString())
|
||||
// .whereIn("activity.ownerId", following)
|
||||
// .orWhereIn("activity.slateId", subscriptions)
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
date.setSeconds(date.getSeconds() - 1);
|
||||
query = await DB.raw(
|
||||
`${selectQuery} WHERE (?? = ANY(?) OR ?? = ANY(?)) AND ?? < '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
|
||||
[...selectFields, ...conditionFields, "activity.createdAt", "activity.createdAt"]
|
||||
);
|
||||
} else if (latestTimestamp) {
|
||||
//NOTE(martina): for fetching new updates since the last time they loaded
|
||||
let date = new Date(latestTimestamp);
|
||||
date.setSeconds(date.getSeconds() + 1);
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.whereRaw("?? > ? and ?? = ? and (?? = any(?) or ?? = any(?))", [
|
||||
"activity.createdAt",
|
||||
date.toISOString(),
|
||||
"activity.type",
|
||||
"CREATE_SLATE_OBJECT",
|
||||
"activity.ownerId",
|
||||
following,
|
||||
"activity.slateId",
|
||||
subscriptions,
|
||||
])
|
||||
// .where("activity.createdAt", ">", date.toISOString())
|
||||
// .where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
// .whereIn("activity.ownerId", following)
|
||||
// .orWhereIn("activity.slateId", subscriptions)
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
query = await DB.raw(
|
||||
`${selectQuery} WHERE (?? = ANY(?) OR ?? = ANY(?)) AND ?? > '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
|
||||
[...selectFields, ...conditionFields, "activity.createdAt", "activity.createdAt"]
|
||||
);
|
||||
} else {
|
||||
//NOTE(martina): for the first fetch they make, when they have not loaded any explore events yet
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.whereRaw("?? = ? and (?? = any(?) or ?? = any(?))", [
|
||||
"activity.type",
|
||||
"CREATE_SLATE_OBJECT",
|
||||
"activity.ownerId",
|
||||
following,
|
||||
"activity.slateId",
|
||||
subscriptions,
|
||||
])
|
||||
// .where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
// .whereIn("activity.ownerId", following)
|
||||
// .orWhereIn("activity.slateId", subscriptions)
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
query = await DB.raw(
|
||||
`${selectQuery} WHERE ?? = ANY(?) OR ?? = ANY(?) ORDER BY ?? DESC LIMIT 100`,
|
||||
[...selectFields, ...conditionFields, "activity.createdAt"]
|
||||
);
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return [];
|
||||
if (query?.rows) {
|
||||
query = query.rows;
|
||||
} else {
|
||||
query = [];
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(query));
|
||||
},
|
||||
errorFn: async (e) => {
|
||||
Logging.error({
|
||||
errorFn: async () => {
|
||||
console.log({
|
||||
error: true,
|
||||
decorator: "GET_ACTIVITY_FOR_USER_ID",
|
||||
});
|
||||
|
@ -1,92 +1,133 @@
|
||||
import * as Logging from "~/common/logging";
|
||||
import * as Constants from "~/node_common/constants";
|
||||
|
||||
import { runQuery } from "~/node_common/data/utilities";
|
||||
|
||||
export default async ({ earliestTimestamp, latestTimestamp }) => {
|
||||
const slateFilesFields = ["files", "slate_files.createdAt", "files.id", "objects"];
|
||||
const slateFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
|
||||
|
||||
const slateFields = [
|
||||
"slate_table",
|
||||
"slates.id",
|
||||
"slates.slatename",
|
||||
"slates.data",
|
||||
"slates.ownerId",
|
||||
"slates.isPublic",
|
||||
"slates.subscriberCount",
|
||||
"slates.fileCount",
|
||||
...slateFilesFields,
|
||||
"slates",
|
||||
"slate_files",
|
||||
"slate_files.slateId",
|
||||
"slates.id",
|
||||
"files",
|
||||
"files.id",
|
||||
"slate_files.fileId",
|
||||
"slates.id",
|
||||
];
|
||||
const slateQuery = `WITH ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${slateFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
|
||||
|
||||
const userFilesFields = ["files", "files.createdAt", "files.id", "objects"];
|
||||
const userFilesQuery = `coalesce(json_agg(?? order by ?? asc) filter (where ?? is not null), '[]') as ??`;
|
||||
const userFields = [
|
||||
"user_table",
|
||||
"users.id",
|
||||
"users.createdAt",
|
||||
"users.username",
|
||||
"users.data",
|
||||
"users.followerCount",
|
||||
"users.fileCount",
|
||||
"users.slateCount",
|
||||
...userFilesFields,
|
||||
"users",
|
||||
"files",
|
||||
"users.id",
|
||||
"files.ownerId",
|
||||
"users.id",
|
||||
];
|
||||
const userQuery = `, ?? as (SELECT ??, ??, ??, ??, ??, ??, ??, ${userFilesQuery} FROM ?? LEFT JOIN ?? on ?? = ?? GROUP BY ??)`;
|
||||
|
||||
const fileFields = [
|
||||
"files_table",
|
||||
"files.*",
|
||||
...Constants.userPreviewProperties,
|
||||
"owner",
|
||||
"files",
|
||||
"users",
|
||||
"files.ownerId",
|
||||
"users.id",
|
||||
];
|
||||
const fileQuery = `, ?? as (SELECT ??, json_build_object('id', ??, 'data', ??, 'username', ??) as ?? FROM ?? LEFT JOIN ?? on ?? = ??)`;
|
||||
|
||||
const selectFields = [
|
||||
...slateFields,
|
||||
...userFields,
|
||||
...fileFields,
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"slate_table",
|
||||
"slate",
|
||||
"files_table",
|
||||
"file",
|
||||
"user_table",
|
||||
"user",
|
||||
"owners",
|
||||
"owner",
|
||||
"activity",
|
||||
"slate_table",
|
||||
"slate_table.id",
|
||||
"activity.slateId",
|
||||
"user_table",
|
||||
"user_table.id",
|
||||
"activity.userId",
|
||||
"files_table",
|
||||
"files_table.id",
|
||||
"activity.fileId",
|
||||
"user_table",
|
||||
"owners",
|
||||
"owners.id",
|
||||
"activity.ownerId",
|
||||
];
|
||||
const selectQuery = `${slateQuery} ${userQuery} ${fileQuery} SELECT ??, ??, ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ??, row_to_json(??) as ?? FROM ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? ON ?? = ?? LEFT JOIN ?? AS ?? ON ?? = ??`;
|
||||
|
||||
return await runQuery({
|
||||
label: "GET_EXPLORE",
|
||||
queryFn: async (DB) => {
|
||||
const users = () => DB.raw("row_to_json(??) as ??", ["users", "owner"]);
|
||||
|
||||
const slates = () => DB.raw("row_to_json(??) as ??", ["slates", "slate"]);
|
||||
|
||||
const files = () => DB.raw("row_to_json(??) as ??", ["files", "file"]);
|
||||
|
||||
let query;
|
||||
if (earliestTimestamp) {
|
||||
//NOTE(martina): for pagination, fetching the "next 100" results
|
||||
let date = new Date(earliestTimestamp);
|
||||
let s = date.getSeconds();
|
||||
if (s < 0) {
|
||||
s = 60 + s;
|
||||
}
|
||||
date.setSeconds(s - 1);
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.where("activity.createdAt", "<", date.toISOString())
|
||||
.where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
date.setSeconds(date.getSeconds() - 1);
|
||||
query = await DB.raw(
|
||||
`${selectQuery} WHERE ?? < '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
|
||||
[...selectFields, "activity.createdAt", "activity.createdAt"]
|
||||
);
|
||||
} else if (latestTimestamp) {
|
||||
//NOTE(martina): for fetching new updates since the last time they loaded
|
||||
let date = new Date(latestTimestamp);
|
||||
date.setSeconds(date.getSeconds() + 1);
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.where("activity.createdAt", ">", date.toISOString())
|
||||
.where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
query = await DB.raw(
|
||||
`${selectQuery} WHERE ?? > '${date.toISOString()}' ORDER BY ?? DESC LIMIT 100`,
|
||||
[...selectFields, "activity.createdAt", "activity.createdAt"]
|
||||
);
|
||||
} else {
|
||||
//NOTE(martina): for the first fetch they make, when they have not loaded any explore events yet
|
||||
query = await DB.select(
|
||||
"activity.id",
|
||||
"activity.type",
|
||||
query = await DB.raw(`${selectQuery} ORDER BY ?? DESC LIMIT 100`, [
|
||||
...selectFields,
|
||||
"activity.createdAt",
|
||||
"activity.slateId",
|
||||
// users(),
|
||||
// slates(),
|
||||
files()
|
||||
)
|
||||
.from("activity")
|
||||
// .join("users", "users.id", "=", "activity.ownerId")
|
||||
.leftJoin("files", "files.id", "=", "activity.fileId")
|
||||
// .leftJoin("slates", "slates.id", "=", "activity.slateId")
|
||||
.where("activity.type", "CREATE_SLATE_OBJECT")
|
||||
.orderBy("activity.createdAt", "desc")
|
||||
.limit(96);
|
||||
]);
|
||||
}
|
||||
|
||||
if (!query || query.error) {
|
||||
return [];
|
||||
if (query?.rows) {
|
||||
query = query.rows;
|
||||
} else {
|
||||
query = [];
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(query));
|
||||
},
|
||||
errorFn: async (e) => {
|
||||
Logging.error({
|
||||
errorFn: async () => {
|
||||
console.log({
|
||||
error: true,
|
||||
decorator: "GET_EXPLORE",
|
||||
});
|
||||
|
@ -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) {
|
||||
|
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;
|
||||
@ -178,7 +188,7 @@ export const getById = async ({ id }) => {
|
||||
} else if (each.data.type && each.data.type.startsWith("application/pdf")) {
|
||||
pdfBytes += each.data.size;
|
||||
}
|
||||
let coverImage = each.data.coverImage;
|
||||
let { coverImage } = each.data;
|
||||
if (coverImage && !cids[coverImage.cid]) {
|
||||
imageBytes += coverImage.data.size;
|
||||
cids[coverImage.cid] = true;
|
||||
@ -223,6 +233,8 @@ export const getById = async ({ id }) => {
|
||||
subscriptions,
|
||||
following,
|
||||
followers,
|
||||
likes,
|
||||
libraryCids,
|
||||
};
|
||||
|
||||
return viewer;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,17 +5,20 @@ import * as Window from "~/common/window";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as ActivityUtilities from "~/common/activity-utilities";
|
||||
|
||||
import { GlobalCarousel } from "~/components/system/components/GlobalCarousel";
|
||||
import { css } from "@emotion/react";
|
||||
import { TabGroup, PrimaryTabGroup, SecondaryTabGroup } from "~/components/core/TabGroup";
|
||||
import { SecondaryTabGroup } from "~/components/core/TabGroup";
|
||||
import { LoaderSpinner } from "~/components/system/components/Loaders";
|
||||
import { Link } from "~/components/core/Link";
|
||||
|
||||
import EmptyState from "~/components/core/EmptyState";
|
||||
import ScenePage from "~/components/core/ScenePage";
|
||||
import SlateMediaObjectPreview from "~/components/core/SlateMediaObjectPreview";
|
||||
import ObjectPreview from "~/components/core/ObjectPreview";
|
||||
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
|
||||
import ActivityObjectPreview from "~/components/core/ActivityObjectPreview";
|
||||
|
||||
const STYLES_LOADER = css`
|
||||
display: flex;
|
||||
@ -30,11 +33,9 @@ const STYLES_IMAGE_BOX = css`
|
||||
position: relative;
|
||||
box-shadow: ${Constants.shadow.lightSmall};
|
||||
margin: 10px;
|
||||
|
||||
:hover {
|
||||
box-shadow: ${Constants.shadow.lightMedium};
|
||||
}
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
@ -78,48 +79,23 @@ const STYLES_GRADIENT = css`
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
overflow: hidden;
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_ACTIVITY_GRID = css`
|
||||
margin: -10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: ${Constants.sizes.mobile}px) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
class ActivitySquare extends React.Component {
|
||||
state = {
|
||||
showText: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const item = this.props.item;
|
||||
const size = this.props.size;
|
||||
// const isImage =
|
||||
// Validations.isPreviewableImage(item.file.data.type) || !!item.file.data.coverImage;
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
css={STYLES_IMAGE_BOX}
|
||||
style={{ width: size, height: size }}
|
||||
onMouseEnter={() => this.setState({ showText: true })}
|
||||
onMouseLeave={() => this.setState({ showText: false })}
|
||||
>
|
||||
<SlateMediaObjectPreview
|
||||
file={item.file}
|
||||
centeredImage
|
||||
// iconOnly
|
||||
style={{ border: "none" }}
|
||||
imageStyle={{ border: "none" }}
|
||||
/>
|
||||
<div>
|
||||
<ObjectPreview file={item.file} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -154,15 +130,7 @@ const ActivityRectangle = ({ item, width, height }) => {
|
||||
let numObjects = item.slate?.objects?.length || 0;
|
||||
return (
|
||||
<div css={STYLES_IMAGE_BOX} style={{ width, height }}>
|
||||
{file ? (
|
||||
<SlateMediaObjectPreview
|
||||
file={file}
|
||||
centeredImage
|
||||
iconOnly
|
||||
style={{ border: "none" }}
|
||||
imageStyle={{ border: "none" }}
|
||||
/>
|
||||
) : null}
|
||||
{file ? <ObjectPreview file={file} /> : null}
|
||||
<div css={STYLES_GRADIENT} />
|
||||
<div css={STYLES_TEXT_AREA}>
|
||||
<div
|
||||
@ -247,9 +215,20 @@ export default class SceneActivity extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
getTab = () => {
|
||||
if (!this.props.viewer) {
|
||||
return "explore";
|
||||
}
|
||||
return this.props.page.params?.tab || "explore";
|
||||
};
|
||||
|
||||
fetchActivityItems = async (update = false) => {
|
||||
if (this.state.loading === "loading") return;
|
||||
let tab = this.getTab();
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
>>>>>>> e3ef4c62... added activity grouping function
|
||||
const isExplore = tab === "explore";
|
||||
this.setState({ loading: "loading" });
|
||||
let activity;
|
||||
@ -282,13 +261,11 @@ export default class SceneActivity extends React.Component {
|
||||
}
|
||||
|
||||
let newItems = response.data || [];
|
||||
newItems = ActivityUtilities.processActivity(newItems);
|
||||
|
||||
if (update) {
|
||||
activity.unshift(...newItems);
|
||||
this.counter = 0;
|
||||
activity = this.formatActivity(activity);
|
||||
} else {
|
||||
newItems = this.formatActivity(newItems);
|
||||
activity.push(...newItems);
|
||||
}
|
||||
|
||||
@ -304,52 +281,11 @@ export default class SceneActivity extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
formatActivity = (userActivity) => {
|
||||
let activity = [];
|
||||
for (let item of userActivity) {
|
||||
// if (item.slate && !item.slate.isPublic) {
|
||||
// continue;
|
||||
// }
|
||||
if (item.type === "CREATE_SLATE_OBJECT") {
|
||||
//&& item.slate && item.file
|
||||
activity.push(item);
|
||||
} else if (item.type === "CREATE_SLATE" && item.slate) {
|
||||
activity.push(item);
|
||||
}
|
||||
}
|
||||
return activity; //NOTE(martina): because now it's only things of CREATE_SLATE_OBJECT type, so all square and don't need reordering
|
||||
//NOTE(martina): rearrange order to always get an even row of 6 squares
|
||||
//TODO(martina): improve this. will fail if there are no more squares left to "swap" with at the end, and you'll end up wtih an empty space
|
||||
// let activity = userActivity || [];
|
||||
// for (let i = 0; i < activity.length; i++) {
|
||||
// let item = activity[i];
|
||||
// if (item.type === "CREATE_SLATE") {
|
||||
// this.counter += 2;
|
||||
// } else if (item.type === "CREATE_SLATE_OBJECT") {
|
||||
// this.counter += 1;
|
||||
// }
|
||||
// if (this.counter === 6) {
|
||||
// this.counter = 0;
|
||||
// } else if (this.counter > 6) {
|
||||
// let j = i - 1;
|
||||
// while (activity[j].type !== "CREATE_SLATE_OBJECT") {
|
||||
// j -= 1;
|
||||
// }
|
||||
// let temp = activity[j];
|
||||
// activity[j] = activity[i];
|
||||
// activity[i] = temp;
|
||||
// this.counter = 0;
|
||||
// i -= 1;
|
||||
// }
|
||||
// }
|
||||
// return activity;
|
||||
};
|
||||
|
||||
calculateWidth = () => {
|
||||
let windowWidth = window.innerWidth;
|
||||
let imageSize;
|
||||
if (windowWidth < Constants.sizes.mobile) {
|
||||
imageSize = windowWidth - 2 * 24; //(windowWidth - 2 * 24 - 20) / 2;
|
||||
imageSize = windowWidth - 2 * 24;
|
||||
} else {
|
||||
imageSize = (windowWidth - 2 * 56 - 5 * 20) / 6;
|
||||
}
|
||||
@ -364,7 +300,6 @@ export default class SceneActivity extends React.Component {
|
||||
render() {
|
||||
let tab = this.getTab();
|
||||
let activity;
|
||||
|
||||
if (this.props.viewer) {
|
||||
activity =
|
||||
tab === "activity" ? this.props.viewer?.activity || [] : this.props.viewer?.explore || [];
|
||||
@ -372,23 +307,12 @@ export default class SceneActivity extends React.Component {
|
||||
activity = this.state.explore || [];
|
||||
}
|
||||
|
||||
let items = activity
|
||||
.filter((item) => item.type === "CREATE_SLATE_OBJECT")
|
||||
.map((item) => {
|
||||
return {
|
||||
...item.file,
|
||||
slateId: item.slateId,
|
||||
// slate: item.slate,
|
||||
// owner: item.owner?.username,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<WebsitePrototypeWrapper
|
||||
title={`${this.props.page.pageTitle} • Slate`}
|
||||
url={`${Constants.hostname}${this.props.page.pathname}`}
|
||||
>
|
||||
<ScenePage>
|
||||
<ScenePage style={{ backgroundColor: "#F2F2F7" }}>
|
||||
{this.props.viewer && (
|
||||
<SecondaryTabGroup
|
||||
tabs={[
|
||||
@ -413,12 +337,11 @@ export default class SceneActivity extends React.Component {
|
||||
this.setState({ carouselIndex: index });
|
||||
}}
|
||||
isMobile={this.props.isMobile}
|
||||
// params={this.props.page.params}
|
||||
isOwner={false}
|
||||
/>
|
||||
{activity.length ? (
|
||||
<div>
|
||||
<div css={STYLES_ACTIVITY_GRID}>
|
||||
<div css={Styles.OBJECTS_PREVIEW_GRID}>
|
||||
{activity.map((item, i) => {
|
||||
if (item.type === "CREATE_SLATE") {
|
||||
return (
|
||||
@ -426,25 +349,10 @@ export default class SceneActivity extends React.Component {
|
||||
redirect
|
||||
key={i}
|
||||
disabled={this.props.isMobile ? false : true}
|
||||
// params={
|
||||
// this.props.isMobile
|
||||
// ? null
|
||||
// : { ...this.props.page.params, cid: item.file.cid }
|
||||
// }
|
||||
href={`/$/slate/${item.slateId}`}
|
||||
onAction={this.props.onAction}
|
||||
onClick={() => this.setState({ carouselIndex: i })}
|
||||
>
|
||||
{/* <span
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
this.props.onAction({
|
||||
type: "NAVIGATE",
|
||||
value: "NAV_SLATE",
|
||||
data: item.slate,
|
||||
})
|
||||
}
|
||||
> */}
|
||||
<ActivityRectangle
|
||||
width={
|
||||
this.props.isMobile
|
||||
@ -454,7 +362,6 @@ export default class SceneActivity extends React.Component {
|
||||
height={this.state.imageSize}
|
||||
item={item}
|
||||
/>
|
||||
{/* </span> */}
|
||||
</Link>
|
||||
);
|
||||
} else if (item.type === "CREATE_SLATE_OBJECT") {
|
||||
@ -463,23 +370,9 @@ export default class SceneActivity extends React.Component {
|
||||
redirect
|
||||
key={i}
|
||||
disabled={this.props.isMobile ? false : true}
|
||||
// params={
|
||||
// this.props.isMobile
|
||||
// ? null
|
||||
// : { ...this.props.page.params, cid: item.file.cid }
|
||||
// }
|
||||
href={`/$/slate/${item.slateId}?cid=${item.file.cid}`}
|
||||
onAction={this.props.onAction}
|
||||
onClick={() => this.setState({ carouselIndex: i })}
|
||||
// onClick={
|
||||
// this.props.isMobile
|
||||
// ? () => {}
|
||||
// : () =>
|
||||
// Events.dispatchCustomEvent({
|
||||
// name: "slate-global-open-carousel",
|
||||
// detail: { index: this.getItemIndexById(items, item) },
|
||||
// })
|
||||
// }
|
||||
>
|
||||
<ActivitySquare
|
||||
size={this.state.imageSize}
|
112
scenes/SceneActivity/hooks.js
Normal file
112
scenes/SceneActivity/hooks.js
Normal file
@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as ActivityUtilities from "~/common/activity-utilities";
|
||||
|
||||
const updateExploreFeed = async ({ viewer, state, onAction, setState, update }) => {
|
||||
const currentItems = viewer?.explore?.items || state?.explore?.items || [];
|
||||
const response = await ActivityUtilities.fetchExploreItems({ currentItems, update });
|
||||
if (Events.hasError(response)) return;
|
||||
|
||||
const newItems = response.data;
|
||||
|
||||
const currentFeed = viewer?.explore?.feed || state?.explore?.feed || [];
|
||||
const newFeed = await ActivityUtilities.processActivity(newItems);
|
||||
|
||||
const newState = {
|
||||
items: currentItems.concat(newItems),
|
||||
feed: currentFeed.concat(newFeed),
|
||||
shouldFetchMore: newItems.length > 0,
|
||||
};
|
||||
|
||||
if (viewer) {
|
||||
onAction({ type: "UPDATE_VIEWER", viewer: { explore: newState } });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, explore: newState }));
|
||||
};
|
||||
|
||||
const updateActivityFeed = async ({ viewer, onAction, update }) => {
|
||||
const currentItems = viewer?.activity?.items || [];
|
||||
const response = await ActivityUtilities.fetchActivityItems({ currentItems, viewer, update });
|
||||
if (Events.hasError(response)) return;
|
||||
|
||||
const newItems = response.data;
|
||||
|
||||
const currentFeed = viewer?.activity?.feed || [];
|
||||
const newFeed = ActivityUtilities.processActivity(newItems);
|
||||
|
||||
onAction({
|
||||
type: "UPDATE_VIEWER",
|
||||
viewer: {
|
||||
activity: {
|
||||
feed: currentFeed.concat(newFeed),
|
||||
items: currentItems.concat(newItems),
|
||||
shouldFetchMore: newItems.length > 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// NOTE(amine): get the state for the selected tab.
|
||||
const getState = (viewer, state, tab) => {
|
||||
if (!viewer) return state.explore || [];
|
||||
|
||||
if (tab === "explore") {
|
||||
return viewer?.explore || {};
|
||||
}
|
||||
return viewer?.activity || {};
|
||||
};
|
||||
|
||||
const getTab = (page, viewer) => {
|
||||
if (page.params?.tab) return page.params?.tab;
|
||||
|
||||
if (viewer?.subscriptions?.length || viewer?.following?.length) {
|
||||
return "activity";
|
||||
}
|
||||
return "explore";
|
||||
};
|
||||
|
||||
export function useActivity({ page, viewer, onAction }) {
|
||||
const [state, setState] = React.useState({
|
||||
explore: {
|
||||
feed: [],
|
||||
items: [],
|
||||
shouldFetchMore: true,
|
||||
},
|
||||
loading: {
|
||||
explore: false,
|
||||
activity: false,
|
||||
},
|
||||
});
|
||||
|
||||
const tab = getTab(page, viewer);
|
||||
|
||||
const updateFeed = React.useCallback(
|
||||
async (update) => {
|
||||
const currentState = getState(viewer, state, tab);
|
||||
const { shouldFetchMore } = currentState || {};
|
||||
if (typeof shouldFetchMore === "boolean" && !shouldFetchMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loading[tab]) return;
|
||||
setState((prev) => ({ ...prev, loading: { ...prev.loading, [tab]: true } }));
|
||||
if (viewer && tab === "activity") {
|
||||
await updateActivityFeed({ viewer, onAction, update });
|
||||
} else {
|
||||
await updateExploreFeed({ viewer, onAction, state, setState, update });
|
||||
}
|
||||
setState((prev) => ({ ...prev, loading: { ...prev.loading, [tab]: false } }));
|
||||
},
|
||||
[tab, onAction, state, viewer]
|
||||
);
|
||||
|
||||
const { feed = [] } = getState(viewer, state, tab);
|
||||
React.useEffect(() => {
|
||||
if (feed && feed?.length !== 0) return;
|
||||
updateFeed(true);
|
||||
}, [tab]);
|
||||
|
||||
return { updateFeed, feed, tab, isLoading: state.loading };
|
||||
}
|
86
scenes/SceneActivity/index.js
Normal file
86
scenes/SceneActivity/index.js
Normal file
@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { SecondaryTabGroup } from "~/components/core/TabGroup";
|
||||
import { LoaderSpinner } from "~/components/system/components/Loaders";
|
||||
import { useIntersection } from "common/hooks";
|
||||
import { useActivity } from "./hooks";
|
||||
|
||||
import ScenePage from "~/components/core/ScenePage";
|
||||
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
|
||||
import ActivityGroup from "~/components/core/ActivityGroup";
|
||||
|
||||
const STYLES_GROUPS_CONTAINER = css`
|
||||
margin-top: 32px;
|
||||
& > * + * {
|
||||
margin-top: 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
const STYLES_LOADING_CONTAINER = css`
|
||||
height: 48px;
|
||||
margin-top: 32px;
|
||||
${Styles.CONTAINER_CENTERED}
|
||||
`;
|
||||
|
||||
const STYLES_LOADER = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 400px);
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default function SceneActivity({ page, viewer, external, onAction }) {
|
||||
const { feed, tab, isLoading, updateFeed } = useActivity({
|
||||
page,
|
||||
viewer,
|
||||
onAction,
|
||||
});
|
||||
|
||||
const divRef = React.useRef();
|
||||
useIntersection({
|
||||
ref: divRef,
|
||||
onIntersect: () => {
|
||||
if (feed?.length === 0 || isLoading[tab]) return;
|
||||
updateFeed();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<WebsitePrototypeWrapper
|
||||
title={`${page.pageTitle} • Slate`}
|
||||
url={`${Constants.hostname}${page.pathname}`}
|
||||
>
|
||||
<ScenePage>
|
||||
{viewer && (
|
||||
<SecondaryTabGroup
|
||||
tabs={[
|
||||
{ title: "My network", value: { tab: "activity" } },
|
||||
{ title: "Explore", value: { tab: "explore" } },
|
||||
]}
|
||||
value={tab}
|
||||
onAction={onAction}
|
||||
style={{ marginTop: 0 }}
|
||||
/>
|
||||
)}
|
||||
<div css={STYLES_GROUPS_CONTAINER}>
|
||||
{feed?.map((group) => (
|
||||
<ActivityGroup
|
||||
key={group.id}
|
||||
viewer={viewer}
|
||||
external={external}
|
||||
onAction={onAction}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div ref={divRef} css={feed?.length ? STYLES_LOADING_CONTAINER : STYLES_LOADER}>
|
||||
{isLoading[tab] && <LoaderSpinner style={{ height: 32, width: 32 }} />}
|
||||
</div>
|
||||
</ScenePage>
|
||||
</WebsitePrototypeWrapper>
|
||||
);
|
||||
}
|
@ -466,6 +466,7 @@ export default class SceneFilesFolder extends React.Component {
|
||||
key="scene-files-folder"
|
||||
onAction={this.props.onAction}
|
||||
viewer={this.props.viewer}
|
||||
user={this.props.viewer}
|
||||
items={files}
|
||||
view={tab}
|
||||
resources={this.props.resources}
|
||||
|
@ -2,15 +2,17 @@ import * as React from "react";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { TabGroup, PrimaryTabGroup, SecondaryTabGroup } from "~/components/core/TabGroup";
|
||||
import { ButtonSecondary } from "~/components/system/components/Buttons";
|
||||
import { FileTypeGroup } from "~/components/core/FileTypeIcon";
|
||||
import { Link } from "~/components/core/Link";
|
||||
|
||||
import ScenePage from "~/components/core/ScenePage";
|
||||
import ScenePageHeader from "~/components/core/ScenePageHeader";
|
||||
import SlatePreviewBlocks from "~/components/core/SlatePreviewBlock";
|
||||
import CollectionPreviewBlock from "~/components/core/CollectionPreviewBlock";
|
||||
import SquareButtonGray from "~/components/core/SquareButtonGray";
|
||||
import EmptyState from "~/components/core/EmptyState";
|
||||
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
|
||||
@ -42,57 +44,78 @@ export default class SceneSlates extends React.Component {
|
||||
url={`${Constants.hostname}${this.props.page.pathname}`}
|
||||
>
|
||||
<ScenePage>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 24 }}>
|
||||
<SecondaryTabGroup
|
||||
tabs={[
|
||||
{ title: "My Collections", value: { tab: "collections" } },
|
||||
{ title: "Subscribed", value: { tab: "subscribed" } },
|
||||
]}
|
||||
value={tab}
|
||||
onAction={this.props.onAction}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<SquareButtonGray onClick={this._handleAdd} style={{ marginLeft: 16 }}>
|
||||
<SVG.Plus height="16px" />
|
||||
</SquareButtonGray>
|
||||
</div>
|
||||
{tab === "collections" ? (
|
||||
this.props.viewer.slates?.length ? (
|
||||
<SlatePreviewBlocks
|
||||
isOwner
|
||||
slates={this.props.viewer.slates}
|
||||
username={this.props.viewer.username}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 24 }}>
|
||||
<SecondaryTabGroup
|
||||
tabs={[
|
||||
{ title: "My Collections", value: { tab: "collections" } },
|
||||
{ title: "Subscribed", value: { tab: "subscribed" } },
|
||||
]}
|
||||
value={tab}
|
||||
onAction={this.props.onAction}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<FileTypeGroup />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
Use collections to create mood boards, share files, and organize research.
|
||||
<SquareButtonGray onClick={this._handleAdd} style={{ marginLeft: 16 }}>
|
||||
<SVG.Plus height="16px" />
|
||||
</SquareButtonGray>
|
||||
</div>
|
||||
{tab === "collections" ? (
|
||||
this.props.viewer.slates?.length ? (
|
||||
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
|
||||
{this.props.viewer.slates.map((slate) => (
|
||||
<Link
|
||||
key={slate.id}
|
||||
href={`/$/slate/${slate.id}`}
|
||||
onAction={this.props.onAction}
|
||||
>
|
||||
<CollectionPreviewBlock
|
||||
key={slate.id}
|
||||
collection={slate}
|
||||
viewer={this.props.viewer}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ButtonSecondary onClick={this._handleAdd} style={{ marginTop: 32 }}>
|
||||
Create collection
|
||||
</ButtonSecondary>
|
||||
</EmptyState>
|
||||
)
|
||||
) : null}
|
||||
) : (
|
||||
<EmptyState>
|
||||
<FileTypeGroup />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
Use collections to create mood boards, share files, and organize research.
|
||||
</div>
|
||||
<ButtonSecondary onClick={this._handleAdd} style={{ marginTop: 32 }}>
|
||||
Create collection
|
||||
</ButtonSecondary>
|
||||
</EmptyState>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{tab === "subscribed" ? (
|
||||
subscriptions && subscriptions.length ? (
|
||||
<SlatePreviewBlocks
|
||||
slates={subscriptions}
|
||||
username={null}
|
||||
onAction={this.props.onAction}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>
|
||||
You can follow any public collections on the network.
|
||||
<ButtonSecondary onClick={this._handleSearch} style={{ marginTop: 32 }}>
|
||||
Browse collections
|
||||
</ButtonSecondary>
|
||||
</EmptyState>
|
||||
)
|
||||
) : null}
|
||||
{tab === "subscribed" ? (
|
||||
subscriptions && subscriptions.length ? (
|
||||
<div css={Styles.COLLECTIONS_PREVIEW_GRID}>
|
||||
{subscriptions.map((slate) => (
|
||||
<Link
|
||||
key={slate.id}
|
||||
href={`/$/slate/${slate.id}`}
|
||||
onAction={this.props.onAction}
|
||||
>
|
||||
<CollectionPreviewBlock
|
||||
key={slate.id}
|
||||
collection={slate}
|
||||
viewer={this.props.viewer}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState>
|
||||
You can follow any public collections on the network.
|
||||
<ButtonSecondary onClick={this._handleSearch} style={{ marginTop: 32 }}>
|
||||
Browse collections
|
||||
</ButtonSecondary>
|
||||
</EmptyState>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</ScenePage>
|
||||
</WebsitePrototypeWrapper>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user