Playlists v2: Refactors, touch ups + Queue Mode (#1604)
* Playlists v2 * Style pass * Change playlist items arrange icon * Playlist card body open by default * Refactor collectionEdit components * Paginate & Refactor bid field * Collection page changes * Add Thumbnail optional * Replace extra info for description on collection page * Playlist card right below video on medium screen * Allow editing private collections * Add edit option to menus * Allow deleting a public playlist but keeping a private version * Add queue to Save menu, remove edit option from Builtin pages, show queue on playlists page * Fix scroll to recent persisting on medium screen * Fix adding to queue from menu * Fixes for delete * PublishList: delay mounting Items tab to prevent lock-up (#1783) For a large list, the playlist publish form is unusable (super-slow typing) due to the entire list being mounted despite the tab is not active. The full solution is still to paginate it, but for now, don't mount the tab until it is selected. Add a spinner to indicate something is loading. It's not prefect, but it's throwaway code anyway. At least we can fill in the fields properly now. * Batch-resolve private collections (#1782) * makeSelectClaimForClaimId --> selectClaimForClaimId Move away from the problematic `makeSelect*`, especially in large loops. * Batch-resolve private collections 1758 This alleviates the lock-up that is caused by large number of invidual resolves. There will still be some minor stutter due to the large DOM that React needs to handle -- that is logged in 1758 and will be handled separately. At least the stutter is short (1-2s) and the app is still usable. Private list items are being resolve individually, super slow if the list is large (>100). Published lists doesn't have this issue. doFetchItemsInCollections contains most of the useful logic, but it isn't called for private/built-in lists because it's not an actual claim. Tweaked doFetchItemsInCollections to handle private (UUID-based) collections. * Use persisted state for floating player playlist card body - I find it annoying being open everytime * Fix removing edits from published playlist * Fix scroll on mobile * Allow going editing items from toast * Fix ClaimShareButton * Prevent edit/publish of builtin * Fix async inside forEach * Fix sync on queue edit * Fix autoplayCountdown replay * Fix deleting an item scrolling the playlist * CreatedAt fixes * Remove repost for now * Anon publish fixes * Fix mature case on floating Co-authored-by: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com>
This commit is contained in:
parent
5863ea8df4
commit
83dbe8ec7c
240 changed files with 8236 additions and 3950 deletions
|
@ -5,13 +5,25 @@ import { selectClaimForUri } from 'redux/selectors/claims';
|
|||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function doFetchCostInfoForUri(uri: string) {
|
||||
return (dispatch: Dispatch, getState: GetState) => {
|
||||
return async (dispatch: Dispatch, getState: GetState) => {
|
||||
const state = getState();
|
||||
const claim = selectClaimForUri(state, uri);
|
||||
|
||||
if (!claim) return;
|
||||
|
||||
function resolve(costInfo) {
|
||||
const fee = claim.value ? claim.value.fee : undefined;
|
||||
|
||||
let costInfo;
|
||||
if (fee === undefined) {
|
||||
costInfo = { cost: 0, includesData: true };
|
||||
} else if (fee.currency === 'LBC') {
|
||||
costInfo = { cost: fee.amount, includesData: true };
|
||||
} else {
|
||||
await Lbryio.getExchangeRates().then(({ LBC_USD }) => {
|
||||
costInfo = { cost: fee.amount / LBC_USD, includesData: true };
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_COST_INFO_COMPLETED,
|
||||
data: {
|
||||
|
@ -19,18 +31,7 @@ export function doFetchCostInfoForUri(uri: string) {
|
|||
costInfo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fee = claim.value ? claim.value.fee : undefined;
|
||||
|
||||
if (fee === undefined) {
|
||||
resolve({ cost: 0, includesData: true });
|
||||
} else if (fee.currency === 'LBC') {
|
||||
resolve({ cost: fee.amount, includesData: true });
|
||||
} else {
|
||||
Lbryio.getExchangeRates().then(({ LBC_USD }) => {
|
||||
resolve({ cost: fee.amount / LBC_USD, includesData: true });
|
||||
});
|
||||
}
|
||||
return costInfo;
|
||||
};
|
||||
}
|
||||
|
|
34
flow-typed/Collections.js
vendored
34
flow-typed/Collections.js
vendored
|
@ -2,9 +2,16 @@ declare type Collection = {
|
|||
id: string,
|
||||
items: Array<?string>,
|
||||
name: string,
|
||||
description?: string,
|
||||
thumbnail?: {
|
||||
url?: string,
|
||||
},
|
||||
type: string,
|
||||
createdAt?: ?number,
|
||||
updatedAt: number,
|
||||
totalItems?: number,
|
||||
itemCount?: number,
|
||||
editsCleared?: boolean,
|
||||
sourceId?: string, // if copied, claimId of original collection
|
||||
};
|
||||
|
||||
|
@ -17,11 +24,25 @@ declare type CollectionState = {
|
|||
saved: Array<string>,
|
||||
isResolvingCollectionById: { [string]: boolean },
|
||||
error?: string | null,
|
||||
queue: Collection,
|
||||
};
|
||||
|
||||
declare type CollectionGroup = {
|
||||
[string]: Collection,
|
||||
}
|
||||
};
|
||||
|
||||
declare type CollectionList = Array<Collection>;
|
||||
|
||||
declare type CollectionCreateParams = {
|
||||
name: string,
|
||||
description?: string,
|
||||
thumbnail?: {
|
||||
url?: string,
|
||||
},
|
||||
items: ?Array<string>,
|
||||
type: string,
|
||||
sourceId?: string, // if copied, claimId of original collection
|
||||
};
|
||||
|
||||
declare type CollectionEditParams = {
|
||||
uris?: Array<string>,
|
||||
|
@ -30,4 +51,13 @@ declare type CollectionEditParams = {
|
|||
order?: { from: number, to: number },
|
||||
type?: string,
|
||||
name?: string,
|
||||
}
|
||||
description?: string,
|
||||
thumbnail?: {
|
||||
url?: string,
|
||||
},
|
||||
};
|
||||
|
||||
declare type CollectionFetchParams = {
|
||||
collectionId: string,
|
||||
pageSize?: number,
|
||||
};
|
||||
|
|
19
flow-typed/content.js
vendored
19
flow-typed/content.js
vendored
|
@ -2,19 +2,16 @@
|
|||
|
||||
declare type ContentState = {
|
||||
primaryUri: ?string,
|
||||
playingUri: { uri?: string },
|
||||
playingUri: {
|
||||
uri?: string,
|
||||
collection: PlayingCollection,
|
||||
},
|
||||
positions: { [string]: { [string]: number } }, // claimId: { outpoint: position }
|
||||
history: Array<WatchHistory>,
|
||||
recommendationId: { [string]: string }, // claimId: recommendationId
|
||||
recommendationParentId: { [string]: string }, // claimId: referrerId
|
||||
recommendationUrls: { [string]: Array<string> }, // claimId: [lbryUrls...]
|
||||
recommendationClicks: { [string]: Array<number> }, // "claimId": [clicked indices...]
|
||||
loopList?: { collectionId: string, loop: boolean },
|
||||
shuffleList?: { collectionId: string, newUrls: Array<string> | boolean },
|
||||
// TODO: it's confusing for newUrls to be a boolean --------- ^^^
|
||||
// It can/should be '?Array<string>` instead -- set it to null, then clients
|
||||
// can cast it to a boolean. That, or rename the variable to `shuffle` if you
|
||||
// don't care about the URLs.
|
||||
lastViewedAnnouncement: ?string, // undefined = not seen in wallet.
|
||||
recsysEntries: { [ClaimId]: RecsysEntry }, // Persistent shadow copy. The main one resides in RecSys.
|
||||
};
|
||||
|
@ -29,6 +26,12 @@ declare type PlayingUri = {
|
|||
primaryUri?: string,
|
||||
pathname?: string,
|
||||
commentId?: string,
|
||||
collectionId?: ?string,
|
||||
collection: PlayingCollection,
|
||||
source?: string,
|
||||
};
|
||||
|
||||
declare type PlayingCollection = {
|
||||
collectionId?: ?string,
|
||||
loop?: ?boolean,
|
||||
shuffle?: ?{ newUrls: Array<string> },
|
||||
};
|
||||
|
|
4
flow-typed/notification.js
vendored
4
flow-typed/notification.js
vendored
|
@ -17,6 +17,10 @@ declare type ToastParams = {
|
|||
linkTarget?: string,
|
||||
isError?: boolean,
|
||||
duration?: 'default' | 'long',
|
||||
actionText?: string,
|
||||
action?: () => void,
|
||||
secondaryActionText?: string,
|
||||
secondaryAction?: () => void,
|
||||
};
|
||||
|
||||
declare type Toast = {
|
||||
|
|
|
@ -26,9 +26,24 @@
|
|||
"Tags": "Tags",
|
||||
"Share": "Share",
|
||||
"Play": "Play",
|
||||
"Play All": "Play All",
|
||||
"Start Playing": "Start Playing",
|
||||
"Shuffle Play": "Shuffle Play",
|
||||
"Shuffle": "Shuffle",
|
||||
"Play in Shuffle mode": "Play in Shuffle mode",
|
||||
"Loop": "Loop",
|
||||
"Playlist": "Playlist",
|
||||
"Visibility": "Visibility",
|
||||
"Video Count": "Video Count",
|
||||
"Last updated at": "Last updated at",
|
||||
"You can add videos to your Playlists": "You can add videos to your Playlists",
|
||||
"Do you want to find some content to save for later, or create a brand new playlist?": "Do you want to find some content to save for later, or create a brand new playlist?",
|
||||
"Explore!": "Explore!",
|
||||
"New Playlist": "New Playlist",
|
||||
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
|
||||
"%playlist_item_count% item": "%playlist_item_count% item",
|
||||
"%playlist_item_count% items": "%playlist_item_count% items",
|
||||
"Published as: %playlist_channel%": "Published as: %playlist_channel%",
|
||||
"Report content": "Report content",
|
||||
"Report Content": "Report Content",
|
||||
"Report comment": "Report comment",
|
||||
|
@ -1173,6 +1188,7 @@
|
|||
"Paid": "Paid",
|
||||
"Start at": "Start at",
|
||||
"Include List ID": "Include List ID",
|
||||
"Include Playlist ID": "Include Playlist ID",
|
||||
"Links": "Links",
|
||||
"Download Link": "Download Link",
|
||||
"Mature content is not supported.": "Mature content is not supported.",
|
||||
|
@ -1775,6 +1791,8 @@
|
|||
"How does this work?": "How does this work?",
|
||||
"Introducing Lists": "Introducing Lists",
|
||||
"You have no lists yet. Better start hoarding!": "You have no lists yet. Better start hoarding!",
|
||||
"You have no Playlists yet. Better start hoarding!": "You have no Playlists yet. Better start hoarding!",
|
||||
"Create a Playlist": "Create a Playlist",
|
||||
"Update your livestream": "Update your livestream",
|
||||
"Prepare an upcoming livestream": "Prepare an upcoming livestream",
|
||||
"Edit your post": "Edit your post",
|
||||
|
@ -1958,15 +1976,17 @@
|
|||
"Lists": "Lists",
|
||||
"Watch Later": "Watch Later",
|
||||
"Favorites": "Favorites",
|
||||
"New List": "New List",
|
||||
"New List Title": "New List Title",
|
||||
"Add to Lists": "Add to Lists",
|
||||
"Add to Playlist": "Add to Playlist",
|
||||
"Playlists": "Playlists",
|
||||
"Edit List": "Edit List",
|
||||
"Edit Playlist": "Edit Playlist",
|
||||
"Delete List": "Delete List",
|
||||
"Delete Playlist": "Delete Playlist",
|
||||
"Private": "Private",
|
||||
"Public": "Public",
|
||||
"View List": "View List",
|
||||
"View Playlist": "View Playlist",
|
||||
"Publish List": "Publish List",
|
||||
"Info": "Info",
|
||||
"Publishes": "Publishes",
|
||||
|
@ -1979,7 +1999,7 @@
|
|||
"Credits": "Credits",
|
||||
"Cannot publish empty list": "Cannot publish empty list",
|
||||
"MyAwesomeList": "MyAwesomeList",
|
||||
"My Awesome List": "My Awesome List",
|
||||
"My Awesome Playlist": "My Awesome Playlist",
|
||||
"This list has no items.": "This list has no items.",
|
||||
"1 item": "1 item",
|
||||
"%collectionCount% items": "%collectionCount% items",
|
||||
|
@ -1989,6 +2009,7 @@
|
|||
"URL Selected": "URL Selected",
|
||||
"Keep": "Keep",
|
||||
"Add this claim to a list": "Add this claim to a list",
|
||||
"Add this video to a playlist": "Add this video to a playlist",
|
||||
"List is Empty": "List is Empty",
|
||||
"Confirm List Unpublish": "Confirm List Unpublish",
|
||||
"This will permanently delete the list.": "This will permanently delete the list.",
|
||||
|
@ -2003,8 +2024,8 @@
|
|||
"Remove from Watch Later": "Remove from Watch Later",
|
||||
"Add to Watch Later": "Add to Watch Later",
|
||||
"Added": "Added",
|
||||
"Item removed from %name%": "Item removed from %name%",
|
||||
"Item added to %name%": "Item added to %name%",
|
||||
"Item removed from %playlist_name%": "Item removed from %playlist_name%",
|
||||
"Item added to %playlist_name%": "Item added to %playlist_name%",
|
||||
"Item added to Watch Later": "Item added to Watch Later",
|
||||
"Item removed from Watch Later": "Item removed from Watch Later",
|
||||
"Item added to Favorites": "Item added to Favorites",
|
||||
|
@ -2135,7 +2156,6 @@
|
|||
"Sending...": "Sending...",
|
||||
"Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8": "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8",
|
||||
"Choose %asset%": "Choose %asset%",
|
||||
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
|
||||
"filtered": "filtered",
|
||||
"Failed to synchronize settings. Wait a while before retrying.": "Failed to synchronize settings. Wait a while before retrying.",
|
||||
"You are offline. Check your internet connection.": "You are offline. Check your internet connection.",
|
||||
|
@ -2307,5 +2327,19 @@
|
|||
"Latest Content Link": "Latest Content Link",
|
||||
"Current Livestream Link": "Current Livestream Link",
|
||||
"Back to Odysee Premium": "Back to Odysee Premium",
|
||||
"Add to Queue": "Add to Queue",
|
||||
"Queue Mode": "Queue Mode",
|
||||
"Item added to Queue": "Item added to Queue",
|
||||
"In Queue": "In Queue",
|
||||
"Now playing: --[Which Playlist is currently playing]--": "Now playing:",
|
||||
"Private %lock_icon%": "Private %lock_icon%",
|
||||
"Playlist is Empty": "Playlist is Empty",
|
||||
"Queue": "Queue",
|
||||
"Your Playlists": "Your Playlists",
|
||||
"Default Playlists": "Default Playlists",
|
||||
"Open": "Open",
|
||||
"Support this content": "Support this content",
|
||||
"Repost this content": "Repost this content",
|
||||
"Share this content": "Share this content",
|
||||
"--end--": "--end--"
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
||||
import { withRouter } from 'react-router';
|
||||
import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
|
||||
import AutoplayCountdown from './view';
|
||||
import { selectModal } from 'redux/selectors/app';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
|
||||
/*
|
||||
AutoplayCountdown does not fetch it's own next content to play, it relies on <RecommendedContent> being rendered.
|
||||
|
@ -11,6 +11,11 @@ import { selectModal } from 'redux/selectors/app';
|
|||
const select = (state, props) => ({
|
||||
nextRecommendedClaim: makeSelectClaimForUri(props.nextRecommendedUri)(state),
|
||||
modal: selectModal(state),
|
||||
isMature: selectClaimIsNsfwForUri(state, props.uri),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, null)(AutoplayCountdown));
|
||||
const perform = {
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(AutoplayCountdown);
|
||||
|
|
|
@ -6,33 +6,40 @@ import I18nMessage from 'component/i18nMessage';
|
|||
import { withRouter } from 'react-router';
|
||||
import debounce from 'util/debounce';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
|
||||
const DEBOUNCE_SCROLL_HANDLER_MS = 150;
|
||||
const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown';
|
||||
|
||||
type Props = {
|
||||
history: { push: (string) => void },
|
||||
uri?: string,
|
||||
nextRecommendedClaim: ?StreamClaim,
|
||||
nextRecommendedUri: string,
|
||||
modal: { id: string, modalProps: {} },
|
||||
skipPaid: boolean,
|
||||
skipMature: boolean,
|
||||
isMature: boolean,
|
||||
doNavigate: () => void,
|
||||
doReplay: () => void,
|
||||
doPrevious: () => void,
|
||||
onCanceled: () => void,
|
||||
doOpenModal: (id: string, props: {}) => void,
|
||||
};
|
||||
|
||||
function AutoplayCountdown(props: Props) {
|
||||
const {
|
||||
uri,
|
||||
nextRecommendedUri,
|
||||
nextRecommendedClaim,
|
||||
history: { push },
|
||||
modal,
|
||||
skipPaid,
|
||||
skipMature,
|
||||
isMature,
|
||||
doNavigate,
|
||||
doReplay,
|
||||
doPrevious,
|
||||
onCanceled,
|
||||
doOpenModal,
|
||||
} = props;
|
||||
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
|
||||
|
||||
|
@ -44,6 +51,8 @@ function AutoplayCountdown(props: Props) {
|
|||
const [timerPaused, setTimerPaused] = React.useState(false);
|
||||
const anyModalPresent = modal !== undefined && modal !== null;
|
||||
const isTimerPaused = timerPaused || anyModalPresent;
|
||||
const shouldSkipMature = skipMature && isMature;
|
||||
const skipCurrentVideo = skipPaid || shouldSkipMature;
|
||||
|
||||
function isAnyInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
|
@ -58,7 +67,9 @@ function AutoplayCountdown(props: Props) {
|
|||
}
|
||||
|
||||
function getMsgPlayingNext() {
|
||||
if (skipPaid) {
|
||||
if (shouldSkipMature) {
|
||||
return __('Skipping mature content in %seconds_left% seconds...', { seconds_left: timer });
|
||||
} else if (skipPaid) {
|
||||
return __('Playing next free content in %seconds_left% seconds...', { seconds_left: timer });
|
||||
} else {
|
||||
return __('Playing in %seconds_left% seconds...', { seconds_left: timer });
|
||||
|
@ -89,7 +100,7 @@ function AutoplayCountdown(props: Props) {
|
|||
interval = setInterval(() => {
|
||||
const newTime = timer - 1;
|
||||
if (newTime === 0) {
|
||||
if (skipPaid) setTimer(countdownTime);
|
||||
if (skipCurrentVideo) setTimer(countdownTime);
|
||||
doNavigate();
|
||||
} else {
|
||||
setTimer(timer - 1);
|
||||
|
@ -100,7 +111,7 @@ function AutoplayCountdown(props: Props) {
|
|||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [timer, doNavigate, push, timerCanceled, isTimerPaused, nextRecommendedUri, skipPaid]);
|
||||
}, [doNavigate, isTimerPaused, nextRecommendedUri, skipCurrentVideo, timer, timerCanceled]);
|
||||
|
||||
if (timerCanceled || !nextRecommendedUri) {
|
||||
return null;
|
||||
|
@ -138,7 +149,7 @@ function AutoplayCountdown(props: Props) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{skipPaid && doPrevious && (
|
||||
{skipCurrentVideo && doPrevious && (
|
||||
<div>
|
||||
<Button
|
||||
label={__('Play Previous')}
|
||||
|
@ -150,12 +161,16 @@ function AutoplayCountdown(props: Props) {
|
|||
)}
|
||||
<div>
|
||||
<Button
|
||||
label={skipPaid ? __('Purchase?') : __('Replay?')}
|
||||
label={shouldSkipMature ? undefined : skipPaid ? __('Purchase?') : __('Replay?')}
|
||||
button="link"
|
||||
iconRight={skipPaid ? ICONS.WALLET : ICONS.REPLAY}
|
||||
icon={shouldSkipMature ? undefined : skipPaid ? ICONS.WALLET : ICONS.REPLAY}
|
||||
onClick={() => {
|
||||
setTimerCanceled(true);
|
||||
if (skipPaid) {
|
||||
doOpenModal(MODALS.AFFIRM_PURCHASE, { uri, cancelCb: () => setTimerCanceled(false) });
|
||||
} else {
|
||||
doReplay();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -2,11 +2,13 @@ import Button from './view';
|
|||
import React, { forwardRef } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { selectUser, selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectHasChannels } from 'redux/selectors/claims';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
pathname: state.router.location.pathname,
|
||||
emailVerified: selectUserVerifiedEmail(state),
|
||||
user: selectUser(state),
|
||||
hasChannels: selectHasChannels(state),
|
||||
});
|
||||
|
||||
const ConnectedButton = connect(mapStateToProps)(Button);
|
||||
|
|
|
@ -36,6 +36,8 @@ type Props = {
|
|||
pathname: string,
|
||||
emailVerified: boolean,
|
||||
requiresAuth: ?boolean,
|
||||
requiresChannel: ?boolean,
|
||||
hasChannels: boolean,
|
||||
myref: any,
|
||||
dispatch: any,
|
||||
'aria-label'?: string,
|
||||
|
@ -69,6 +71,8 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
activeClass,
|
||||
emailVerified,
|
||||
requiresAuth,
|
||||
requiresChannel,
|
||||
hasChannels,
|
||||
myref,
|
||||
dispatch, // <button> doesn't know what to do with dispatch
|
||||
pathname,
|
||||
|
@ -195,8 +199,12 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (requiresAuth && !emailVerified) {
|
||||
let redirectUrl = `/$/${PAGES.AUTH}?redirect=${pathname}`;
|
||||
if ((requiresAuth && !emailVerified) || (requiresChannel && !hasChannels)) {
|
||||
// requiresChannel can be used for both requiresAuth and requiresChannel,
|
||||
// since if the button requiresChannel, it also implies it requiresAuth in order to proceed
|
||||
// so using requiresChannel means: unauth users are sent to signup, auth users to create channel
|
||||
const page = !emailVerified ? PAGES.AUTH : PAGES.CHANNEL_NEW;
|
||||
let redirectUrl = `/$/${page}?redirect=${pathname}`;
|
||||
|
||||
if (authSrc) {
|
||||
redirectUrl += `&src=${authSrc}`;
|
||||
|
|
37
ui/component/buttonAddToQueue/index.js
Normal file
37
ui/component/buttonAddToQueue/index.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import { selectCollectionForIdHasClaimUrl, selectUrlsForCollectionId } from 'redux/selectors/collections';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import ButtonAddToQueue from './view';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { doUriInitiatePlay, doSetPlayingUri } from 'redux/actions/content';
|
||||
import { selectPlayingUri } from 'redux/selectors/content';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
const playingUri = selectPlayingUri(state);
|
||||
const { collectionId } = playingUri.collection || {};
|
||||
const { permanent_url: playingUrl } = selectClaimForUri(state, playingUri.uri) || {};
|
||||
|
||||
return {
|
||||
playingUri,
|
||||
playingUrl,
|
||||
hasClaimInQueue: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, uri),
|
||||
hasPlayingUriInQueue: Boolean(
|
||||
playingUrl && selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, playingUrl)
|
||||
),
|
||||
playingCollectionUrls:
|
||||
collectionId && collectionId !== COLLECTIONS_CONSTS.QUEUE_ID && selectUrlsForCollectionId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doToast,
|
||||
doCollectionEdit,
|
||||
doUriInitiatePlay,
|
||||
doSetPlayingUri,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(ButtonAddToQueue);
|
97
ui/component/buttonAddToQueue/view.jsx
Normal file
97
ui/component/buttonAddToQueue/view.jsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import { MenuItem } from '@reach/menu-button';
|
||||
import Icon from 'component/common/icon';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
focusable: boolean,
|
||||
menuItem?: boolean,
|
||||
// -- redux --
|
||||
hasClaimInQueue: boolean,
|
||||
hasPlayingUriInQueue: boolean,
|
||||
playingUri: PlayingUri,
|
||||
playingUrl: ?string,
|
||||
playingCollectionUrls: ?Array<string>,
|
||||
doToast: (props: { message: string }) => void,
|
||||
doCollectionEdit: (id: string, any) => void,
|
||||
doUriInitiatePlay: (playingOptions: PlayingUri, isPlayable?: boolean, isFloating?: boolean) => void,
|
||||
doSetPlayingUri: (props: any) => void,
|
||||
};
|
||||
|
||||
function ButtonAddToQueue(props: Props) {
|
||||
const {
|
||||
uri,
|
||||
focusable = true,
|
||||
menuItem,
|
||||
hasClaimInQueue,
|
||||
hasPlayingUriInQueue,
|
||||
playingUri,
|
||||
playingUrl,
|
||||
playingCollectionUrls,
|
||||
doToast,
|
||||
doCollectionEdit,
|
||||
doUriInitiatePlay,
|
||||
doSetPlayingUri,
|
||||
} = props;
|
||||
|
||||
function handleQueue(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
doToast({ message: hasClaimInQueue ? __('Item removed from Queue') : __('Item added to Queue') });
|
||||
|
||||
const itemsToAdd = playingCollectionUrls || [playingUrl];
|
||||
doCollectionEdit(COLLECTIONS_CONSTS.QUEUE_ID, {
|
||||
uris: playingUrl && playingUrl !== uri && !hasPlayingUriInQueue ? [...itemsToAdd, uri] : [uri],
|
||||
remove: hasClaimInQueue,
|
||||
type: 'playlist',
|
||||
});
|
||||
|
||||
if (!hasClaimInQueue) {
|
||||
const paramsToAdd = {
|
||||
collection: { collectionId: COLLECTIONS_CONSTS.QUEUE_ID },
|
||||
source: COLLECTIONS_CONSTS.QUEUE_ID,
|
||||
};
|
||||
|
||||
if (playingUrl) {
|
||||
// adds the queue collection id to the playingUri data so it can be used and updated by other components
|
||||
if (!hasPlayingUriInQueue) doSetPlayingUri({ ...playingUri, ...paramsToAdd });
|
||||
} else {
|
||||
// There is nothing playing and added a video to queue -> the first item will play on the floating player with the list open
|
||||
doUriInitiatePlay({ uri, ...paramsToAdd }, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// label that is shown after hover
|
||||
const label = !hasClaimInQueue ? __('Add to Queue') : __('In Queue');
|
||||
|
||||
if (menuItem) {
|
||||
return (
|
||||
<MenuItem className="comment__menu-option" onSelect={handleQueue}>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={hasClaimInQueue ? ICONS.PLAYLIST_FILLED : ICONS.PLAYLIST} />
|
||||
{hasClaimInQueue ? __('In Queue') : __('Add to Queue')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="claim-preview__hover-actions third-item">
|
||||
<Button
|
||||
title={__('Queue Mode')}
|
||||
label={label}
|
||||
className="button--file-action"
|
||||
icon={hasClaimInQueue ? ICONS.PLAYLIST_FILLED : ICONS.PLAYLIST_ADD}
|
||||
onClick={(e) => handleQueue(e)}
|
||||
tabIndex={focusable ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ButtonAddToQueue;
|
|
@ -17,6 +17,7 @@ import { MINIMUM_PUBLISH_BID, INVALID_NAME_ERROR, ESTIMATED_FEE } from 'constant
|
|||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
|
||||
import Card from 'component/common/card';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as PUBLISH from 'constants/publish';
|
||||
import analytics from 'analytics';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
|
||||
|
@ -28,8 +29,6 @@ import Gerbil from 'component/channelThumbnail/gerbil.png';
|
|||
|
||||
const NEKODEV = false; // Temporary flag to hide unfinished progress
|
||||
|
||||
const LANG_NONE = 'none';
|
||||
|
||||
const MAX_TAG_SELECT = 5;
|
||||
|
||||
type Props = {
|
||||
|
@ -189,14 +188,14 @@ function ChannelForm(props: Props) {
|
|||
function handleLanguageChange(index, code) {
|
||||
let langs = [...languageParam];
|
||||
if (index === 0) {
|
||||
if (code === LANG_NONE) {
|
||||
if (code === PUBLISH.LANG_NONE) {
|
||||
// clear all
|
||||
langs = [];
|
||||
} else {
|
||||
langs[0] = code;
|
||||
}
|
||||
} else {
|
||||
if (code === LANG_NONE || code === langs[0]) {
|
||||
if (code === PUBLISH.LANG_NONE || code === langs[0]) {
|
||||
langs.splice(1, 1);
|
||||
} else {
|
||||
langs[index] = code;
|
||||
|
@ -501,7 +500,7 @@ function ChannelForm(props: Props) {
|
|||
value={primaryLanguage}
|
||||
helper={__('Your main content language')}
|
||||
>
|
||||
<option key={'pri-langNone'} value={LANG_NONE}>
|
||||
<option key={'pri-langNone'} value={PUBLISH.LANG_NONE}>
|
||||
{__('None selected')}
|
||||
</option>
|
||||
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
|
||||
|
@ -519,7 +518,7 @@ function ChannelForm(props: Props) {
|
|||
disabled={!languageParam[0]}
|
||||
helper={__('Your other content language')}
|
||||
>
|
||||
<option key={'sec-langNone'} value={LANG_NONE}>
|
||||
<option key={'sec-langNone'} value={PUBLISH.LANG_NONE}>
|
||||
{__('None selected')}
|
||||
</option>
|
||||
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
|
||||
|
|
|
@ -14,7 +14,7 @@ import PremiumBadge from 'component/premiumBadge';
|
|||
type Props = {
|
||||
selectedChannelUrl: string, // currently selected channel
|
||||
channels: ?Array<ChannelClaim>,
|
||||
onChannelSelect: (url: string) => void,
|
||||
onChannelSelect?: (id: ?string) => void,
|
||||
hideAnon?: boolean,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
doSetActiveChannel: (claimId: ?string, override?: boolean) => void,
|
||||
|
@ -38,6 +38,7 @@ export default function ChannelSelector(props: Props) {
|
|||
channels,
|
||||
activeChannelClaim,
|
||||
doSetActiveChannel,
|
||||
onChannelSelect,
|
||||
incognito,
|
||||
doSetIncognito,
|
||||
odyseeMembershipByUri,
|
||||
|
@ -63,11 +64,14 @@ export default function ChannelSelector(props: Props) {
|
|||
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
|
||||
|
||||
function handleChannelSelect(channelClaim) {
|
||||
const { claim_id: id } = channelClaim;
|
||||
|
||||
doSetIncognito(false);
|
||||
doSetActiveChannel(channelClaim.claim_id);
|
||||
doSetActiveChannel(id);
|
||||
if (onChannelSelect) onChannelSelect(id);
|
||||
|
||||
if (storeSelection) {
|
||||
doSetDefaultChannel(channelClaim.claim_id);
|
||||
doSetDefaultChannel(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,7 +142,12 @@ export default function ChannelSelector(props: Props) {
|
|||
</MenuItem>
|
||||
))}
|
||||
{!hideAnon && (
|
||||
<MenuItem onSelect={() => doSetIncognito(true)}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
doSetIncognito(true);
|
||||
if (onChannelSelect) onChannelSelect(undefined);
|
||||
}}
|
||||
>
|
||||
<IncognitoSelector />
|
||||
</MenuItem>
|
||||
)}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { parseURI } from 'util/lbryURI';
|
|||
import { getImageProxyUrl } from 'util/thumbnail';
|
||||
import classnames from 'classnames';
|
||||
import Gerbil from './gerbil.png';
|
||||
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
|
||||
import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
|
||||
import OptimizedImage from 'component/optimizedImage';
|
||||
import { AVATAR_DEFAULT } from 'config';
|
||||
import useGetUserMemberships from 'effects/use-get-user-memberships';
|
||||
|
@ -98,12 +98,19 @@ function ChannelThumbnail(props: Props) {
|
|||
if (isGif && !allowGifs) {
|
||||
const url = getImageProxyUrl(channelThumbnail);
|
||||
return (
|
||||
url && (
|
||||
<FreezeframeWrapper
|
||||
src={url}
|
||||
className={classnames('channel-thumbnail', className, { 'channel-thumbnail--xxsmall': xxsmall })}
|
||||
className={classnames('channel-thumbnail', className, {
|
||||
'channel-thumbnail--small': small,
|
||||
'channel-thumbnail--xsmall': xsmall,
|
||||
'channel-thumbnail--xxsmall': xxsmall,
|
||||
'channel-thumbnail--resolving': isResolving,
|
||||
})}
|
||||
>
|
||||
{showMemberBadge && <PremiumBadge {...badgeProps} />}
|
||||
{showMemberBadge ? <PremiumBadge {...badgeProps} /> : null}
|
||||
</FreezeframeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ClaimCollectionAdd from './view';
|
||||
import { withRouter } from 'react-router';
|
||||
import {
|
||||
selectBuiltinCollections,
|
||||
selectMyPublishedCollections,
|
||||
selectMyUnpublishedCollections,
|
||||
} from 'redux/selectors/collections';
|
||||
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
||||
import { doLocalCollectionCreate } from 'redux/actions/collections';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
builtin: selectBuiltinCollections(state),
|
||||
published: selectMyPublishedCollections(state),
|
||||
unpublished: selectMyUnpublishedCollections(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
addCollection: (name, items, type) => dispatch(doLocalCollectionCreate(name, items, type)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(ClaimCollectionAdd));
|
|
@ -1,148 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import type { ElementRef } from 'react';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
import { FormField } from 'component/common/form';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as KEYCODES from 'constants/keycodes';
|
||||
import CollectionSelectItem from 'component/collectionSelectItem';
|
||||
|
||||
type Props = {
|
||||
claim: Claim,
|
||||
builtin: any,
|
||||
published: any,
|
||||
unpublished: any,
|
||||
addCollection: (string, Array<string>, string) => void,
|
||||
closeModal: () => void,
|
||||
uri: string,
|
||||
};
|
||||
|
||||
const ClaimCollectionAdd = (props: Props) => {
|
||||
const { builtin, published, unpublished, addCollection, claim, closeModal, uri } = props;
|
||||
const buttonref: ElementRef<any> = React.useRef();
|
||||
const permanentUrl = claim && claim.permanent_url;
|
||||
const isChannel = claim && claim.value_type === 'channel';
|
||||
|
||||
const [addNewCollection, setAddNewCollection] = React.useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = React.useState('');
|
||||
|
||||
// TODO: when other collection types added, filter list in context
|
||||
// const isPlayable =
|
||||
// claim &&
|
||||
// claim.value &&
|
||||
// // $FlowFixMe
|
||||
// claim.value.stream_type &&
|
||||
// (claim.value.stream_type === 'audio' || claim.value.stream_type === 'video');
|
||||
|
||||
function handleNameInput(e) {
|
||||
const { value } = e.target;
|
||||
setNewCollectionName(value);
|
||||
}
|
||||
|
||||
function handleAddCollection() {
|
||||
addCollection(newCollectionName, [permanentUrl], isChannel ? 'collection' : 'playlist');
|
||||
setNewCollectionName('');
|
||||
setAddNewCollection(false);
|
||||
}
|
||||
|
||||
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
|
||||
if (e.keyCode === KEYCODES.ENTER) {
|
||||
e.preventDefault();
|
||||
buttonref.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
function onTextareaFocus() {
|
||||
window.addEventListener('keydown', altEnterListener);
|
||||
}
|
||||
|
||||
function onTextareaBlur() {
|
||||
window.removeEventListener('keydown', altEnterListener);
|
||||
}
|
||||
|
||||
function handleClearNew() {
|
||||
setNewCollectionName('');
|
||||
setAddNewCollection(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={__('Add To...')}
|
||||
actions={
|
||||
<div className="card__body">
|
||||
{uri && (
|
||||
<fieldset-section>
|
||||
<div className={'card__body-scrollable'}>
|
||||
{(Object.values(builtin): any)
|
||||
// $FlowFixMe
|
||||
.filter((list) => (isChannel ? list.type === 'collection' : list.type === 'playlist'))
|
||||
.map((l) => {
|
||||
const { id } = l;
|
||||
return <CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'builtin'} />;
|
||||
})}
|
||||
{unpublished &&
|
||||
(Object.values(unpublished): any)
|
||||
// $FlowFixMe
|
||||
.filter((list) => (isChannel ? list.type === 'collection' : list.type === 'playlist'))
|
||||
.map((l) => {
|
||||
const { id } = l;
|
||||
return (
|
||||
<CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'unpublished'} />
|
||||
);
|
||||
})}
|
||||
{published &&
|
||||
(Object.values(published): any).map((l) => {
|
||||
// $FlowFixMe
|
||||
const { id } = l;
|
||||
return (
|
||||
<CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'published'} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset-section>
|
||||
)}
|
||||
<fieldset-section>
|
||||
{addNewCollection && (
|
||||
<FormField
|
||||
autoFocus
|
||||
type="text"
|
||||
name="new_collection"
|
||||
value={newCollectionName}
|
||||
label={__('New List Title')}
|
||||
onFocus={onTextareaFocus}
|
||||
onBlur={onTextareaBlur}
|
||||
inputButton={
|
||||
<>
|
||||
<Button
|
||||
button={'alt'}
|
||||
icon={ICONS.ADD}
|
||||
className={'button-toggle'}
|
||||
disabled={!newCollectionName.length}
|
||||
onClick={() => handleAddCollection()}
|
||||
ref={buttonref}
|
||||
/>
|
||||
<Button
|
||||
button={'alt'}
|
||||
className={'button-toggle'}
|
||||
icon={ICONS.REMOVE}
|
||||
onClick={() => handleClearNew()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onChange={handleNameInput}
|
||||
/>
|
||||
)}
|
||||
{!addNewCollection && (
|
||||
<Button button={'link'} label={__('New List')} onClick={() => setAddNewCollection(true)} />
|
||||
)}
|
||||
</fieldset-section>
|
||||
<div className="card__actions">
|
||||
<Button button="primary" label={__('Done')} disabled={addNewCollection} onClick={closeModal} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default ClaimCollectionAdd;
|
|
@ -2,20 +2,17 @@ import { connect } from 'react-redux';
|
|||
import { doOpenModal } from 'redux/actions/app';
|
||||
import CollectionAddButton from './view';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import { makeSelectClaimUrlInCollection } from 'redux/selectors/collections';
|
||||
import { selectClaimSavedForUrl } from 'redux/selectors/collections';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
const claim = selectClaimForUri(state, uri);
|
||||
|
||||
// $FlowFixMe
|
||||
const { permanent_url: permanentUrl, value } = claim || {};
|
||||
const streamType = (value && value.stream_type) || '';
|
||||
const { permanent_url: permanentUrl, value } = selectClaimForUri(state, uri) || {};
|
||||
const { stream_type: streamType } = value || {};
|
||||
|
||||
return {
|
||||
streamType,
|
||||
isSaved: permanentUrl && makeSelectClaimUrlInCollection(permanentUrl)(state),
|
||||
isSaved: permanentUrl && selectClaimSavedForUrl(state, permanentUrl),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -2,40 +2,33 @@
|
|||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import classnames from 'classnames';
|
||||
import Tooltip from 'component/common/tooltip';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
fileAction?: boolean,
|
||||
type?: boolean,
|
||||
// redux
|
||||
streamType: Claim,
|
||||
isSaved: boolean,
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
export default function CollectionAddButton(props: Props) {
|
||||
const { uri, fileAction, type = 'playlist', isSaved, streamType, doOpenModal } = props;
|
||||
function ClaimCollectionAddButton(props: Props) {
|
||||
const { uri, streamType, isSaved, doOpenModal } = props;
|
||||
|
||||
const isPlayable = streamType === 'video' || streamType === 'audio';
|
||||
|
||||
return !isPlayable ? null : (
|
||||
<Tooltip title={__('Add this claim to a list')} arrow={false}>
|
||||
<Button
|
||||
button={!fileAction ? 'alt' : undefined}
|
||||
className={classnames({ 'button--file-action': fileAction })}
|
||||
icon={fileAction ? (!isSaved ? ICONS.ADD : ICONS.STACK) : ICONS.LIBRARY}
|
||||
iconSize={fileAction ? 16 : undefined}
|
||||
label={uri ? (!isSaved ? __('Save') : __('Saved')) : __('New List')}
|
||||
if (!isPlayable) return null;
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
title={__('Add this video to a playlist')}
|
||||
label={!isSaved ? __('Save') : __('Saved')}
|
||||
icon={!isSaved ? ICONS.PLAYLIST_ADD : ICONS.PLAYLIST_FILLED}
|
||||
iconSize={20}
|
||||
requiresAuth
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
doOpenModal(MODALS.COLLECTION_ADD, { uri, type });
|
||||
}}
|
||||
onClick={() => doOpenModal(MODALS.COLLECTION_ADD, { uri })}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimCollectionAddButton;
|
||||
|
|
17
ui/component/claimDescription/index.js
Normal file
17
ui/component/claimDescription/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import ClaimDescription from './view';
|
||||
import { getClaimMetadata } from 'util/claim';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri, description: descriptionProp } = props;
|
||||
|
||||
const claim = !descriptionProp && selectClaimForUri(state, uri);
|
||||
const metadata = claim && getClaimMetadata(claim);
|
||||
|
||||
return {
|
||||
description: descriptionProp || (metadata && metadata.description),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(select)(ClaimDescription);
|
17
ui/component/claimDescription/view.jsx
Normal file
17
ui/component/claimDescription/view.jsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import MarkdownPreview from 'component/common/markdown-preview';
|
||||
|
||||
type Props = {
|
||||
description?: string,
|
||||
};
|
||||
|
||||
function ClaimDescription(props: Props) {
|
||||
const { description } = props;
|
||||
|
||||
return !description ? null : (
|
||||
<MarkdownPreview className="markdown-preview--description" content={description} simpleLinks />
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimDescription;
|
|
@ -10,6 +10,8 @@ import usePersistedState from 'effects/use-persisted-state';
|
|||
import useGetLastVisibleSlot from 'effects/use-get-last-visible-slot';
|
||||
import debounce from 'util/debounce';
|
||||
import ClaimPreviewTile from 'component/claimPreviewTile';
|
||||
import Button from 'component/button';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
|
||||
const Draggable = React.lazy(() =>
|
||||
// $FlowFixMe
|
||||
|
@ -61,7 +63,16 @@ type Props = {
|
|||
unavailableUris?: Array<string>,
|
||||
showMemberBadge?: boolean,
|
||||
inWatchHistory?: boolean,
|
||||
smallThumbnail?: boolean,
|
||||
showIndexes?: boolean,
|
||||
playItemsOnClick?: boolean,
|
||||
disableClickNavigation?: boolean,
|
||||
setActiveListItemRef?: any,
|
||||
setListRef?: any,
|
||||
onHidden: (string) => void,
|
||||
doDisablePlayerDrag?: (disable: boolean) => void,
|
||||
restoreScrollPos?: () => void,
|
||||
setHasActive?: (has: boolean) => void,
|
||||
};
|
||||
|
||||
export default function ClaimList(props: Props) {
|
||||
|
@ -103,12 +114,24 @@ export default function ClaimList(props: Props) {
|
|||
unavailableUris,
|
||||
showMemberBadge,
|
||||
inWatchHistory,
|
||||
smallThumbnail,
|
||||
showIndexes,
|
||||
playItemsOnClick,
|
||||
disableClickNavigation,
|
||||
setActiveListItemRef,
|
||||
setListRef,
|
||||
onHidden,
|
||||
doDisablePlayerDrag,
|
||||
restoreScrollPos,
|
||||
setHasActive,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
|
||||
const uriBuffer = React.useRef([]);
|
||||
|
||||
const currentActiveItem = React.useRef();
|
||||
// Resolve the index for injectedItem, if provided; else injectedIndex will be 'undefined'.
|
||||
const listRef = React.useRef();
|
||||
const findLastVisibleSlot = injectedItem && injectedItem.node && injectedItem.index === undefined;
|
||||
|
@ -201,9 +224,15 @@ export default function ClaimList(props: Props) {
|
|||
swipeLayout={swipeLayout}
|
||||
showEdit={showEdit}
|
||||
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
|
||||
wrapperElement={draggableProvided ? 'div' : undefined}
|
||||
unavailableUris={unavailableUris}
|
||||
showMemberBadge={showMemberBadge}
|
||||
inWatchHistory={inWatchHistory}
|
||||
smallThumbnail={smallThumbnail}
|
||||
showIndexes={showIndexes}
|
||||
playItemsOnClick={playItemsOnClick}
|
||||
disableClickNavigation={disableClickNavigation}
|
||||
doDisablePlayerDrag={doDisablePlayerDrag}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -223,6 +252,40 @@ export default function ClaimList(props: Props) {
|
|||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (setHasActive) {
|
||||
// used in case the active item is deleted
|
||||
setHasActive(sortedUris.some((uri) => activeUri && uri === activeUri));
|
||||
}
|
||||
}, [activeUri, setHasActive, sortedUris]);
|
||||
|
||||
const listRefCb = React.useCallback(
|
||||
(node) => {
|
||||
if (node) {
|
||||
if (droppableProvided) droppableProvided.innerRef(node);
|
||||
if (setListRef) setListRef(node);
|
||||
}
|
||||
},
|
||||
[droppableProvided, setListRef]
|
||||
);
|
||||
|
||||
const listItemCb = React.useCallback(
|
||||
({ node, isActive, draggableProvidedRef }) => {
|
||||
if (node) {
|
||||
if (draggableProvidedRef) draggableProvidedRef(node);
|
||||
|
||||
// currentActiveItem.current !== node prevents re-scrolling during the same render
|
||||
// so it should only auto scroll when the active item switches, the button to scroll is clicked
|
||||
// or the list itself changes (switch between floating player vs file page)
|
||||
if (isActive && setActiveListItemRef && currentActiveItem.current !== node) {
|
||||
setActiveListItemRef(node);
|
||||
currentActiveItem.current = node;
|
||||
}
|
||||
}
|
||||
},
|
||||
[setActiveListItemRef]
|
||||
);
|
||||
|
||||
return tileLayout && !header ? (
|
||||
<>
|
||||
<section ref={listRef} className={classnames('claim-grid', { 'swipe-list': swipeLayout })}>
|
||||
|
@ -269,11 +332,7 @@ export default function ClaimList(props: Props) {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<section
|
||||
className={classnames('claim-list', {
|
||||
'claim-list--small': type === 'small',
|
||||
})}
|
||||
>
|
||||
<section className={classnames('claim-list', { 'claim-list--no-margin': showIndexes })}>
|
||||
{header !== false && (
|
||||
<React.Fragment>
|
||||
{header && (
|
||||
|
@ -311,7 +370,7 @@ export default function ClaimList(props: Props) {
|
|||
'swipe-list': swipeLayout,
|
||||
})}
|
||||
{...(droppableProvided && droppableProvided.droppableProps)}
|
||||
ref={droppableProvided ? droppableProvided.innerRef : listRef}
|
||||
ref={listRefCb}
|
||||
>
|
||||
{droppableProvided ? (
|
||||
<>
|
||||
|
@ -326,13 +385,46 @@ export default function ClaimList(props: Props) {
|
|||
transform = transform.replace(/\(.+,/, '(0,');
|
||||
}
|
||||
|
||||
// doDisablePlayerDrag is a function brought by fileRenderFloating if is floating
|
||||
const isDraggingFromFloatingPlayer = draggableSnapshot.isDragging && doDisablePlayerDrag;
|
||||
const isDraggingFromMobile = draggableSnapshot.isDragging && isMobile;
|
||||
const topForDrawer = Number(
|
||||
// $FlowFixMe
|
||||
document.documentElement?.style?.getPropertyValue('--content-height') || 0
|
||||
);
|
||||
const playerInfo = isDraggingFromFloatingPlayer && document.querySelector('.content__info');
|
||||
const playerElem = isDraggingFromFloatingPlayer && document.querySelector('.content__viewer');
|
||||
const playerTransform = playerElem && playerElem.style.transform;
|
||||
let playerTop =
|
||||
playerTransform &&
|
||||
Number(
|
||||
playerTransform.substring(playerTransform.indexOf(', ') + 2, playerTransform.indexOf('px)'))
|
||||
);
|
||||
if (playerElem && navigator.userAgent.toLowerCase().indexOf('firefox') !== -1) {
|
||||
playerTop -= playerElem.offsetHeight;
|
||||
}
|
||||
|
||||
const style = {
|
||||
...draggableProvided.draggableProps.style,
|
||||
transform,
|
||||
top: isDraggingFromFloatingPlayer
|
||||
? draggableProvided.draggableProps.style.top - playerInfo?.offsetTop - Number(playerTop)
|
||||
: isDraggingFromMobile
|
||||
? draggableProvided.draggableProps.style.top - topForDrawer
|
||||
: draggableProvided.draggableProps.style.top,
|
||||
left: isDraggingFromFloatingPlayer ? undefined : draggableProvided.draggableProps.style.left,
|
||||
right: isDraggingFromFloatingPlayer ? undefined : draggableProvided.draggableProps.style.right,
|
||||
};
|
||||
const isActive = activeUri && uri === activeUri;
|
||||
|
||||
return (
|
||||
<li ref={draggableProvided.innerRef} {...draggableProvided.draggableProps} style={style}>
|
||||
<li
|
||||
ref={(node) =>
|
||||
listItemCb({ node, isActive, draggableProvidedRef: draggableProvided.innerRef })
|
||||
}
|
||||
{...draggableProvided.draggableProps}
|
||||
style={style}
|
||||
>
|
||||
{/* https://github.com/atlassian/react-beautiful-dnd/issues/1756 */}
|
||||
<div style={{ display: 'none' }} {...draggableProvided.dragHandleProps} />
|
||||
{getClaimPreview(uri, index, draggableProvided)}
|
||||
|
@ -355,6 +447,15 @@ export default function ClaimList(props: Props) {
|
|||
</ul>
|
||||
)}
|
||||
|
||||
{restoreScrollPos && (
|
||||
<Button
|
||||
button="secondary"
|
||||
className="claim-list__scroll-to-recent"
|
||||
label={__('Scroll to Playing')}
|
||||
onClick={restoreScrollPos}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!timedOut && urisLength === 0 && !loading && !noEmpty && (
|
||||
<div className="empty empty--centered">{empty || noResultMsg}</div>
|
||||
)}
|
||||
|
|
|
@ -374,7 +374,7 @@ function ClaimListHeader(props: Props) {
|
|||
return (
|
||||
<option key={type} value={type}>
|
||||
{/* i18fixme */}
|
||||
{type === CS.CLAIM_COLLECTION && __('List')}
|
||||
{type === CS.CLAIM_COLLECTION && __('Playlist')}
|
||||
{type === CS.CLAIM_CHANNEL && __('Channel')}
|
||||
{type === CS.CLAIM_REPOST && __('Repost')}
|
||||
{type === CS.FILE_VIDEO && __('Video')}
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectClaimForUri, selectClaimIsMine } from 'redux/selectors/claims';
|
||||
import { doCollectionEdit, doFetchItemsInCollection } from 'redux/actions/collections';
|
||||
import { doFetchItemsInCollection } from 'redux/actions/collections';
|
||||
import { doPrepareEdit } from 'redux/actions/publish';
|
||||
import { doRemovePersonalRecommendation } from 'redux/actions/search';
|
||||
import {
|
||||
makeSelectCollectionForId,
|
||||
makeSelectCollectionForIdHasClaimUrl,
|
||||
makeSelectCollectionIsMine,
|
||||
makeSelectEditedCollectionForId,
|
||||
makeSelectUrlsForCollectionId,
|
||||
selectCollectionForId,
|
||||
selectCollectionForIdHasClaimUrl,
|
||||
selectCollectionIsMine,
|
||||
selectCollectionHasEditsForId,
|
||||
selectUrlsForCollectionId,
|
||||
selectLastUsedCollection,
|
||||
selectCollectionIsEmptyForId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
|
@ -31,8 +32,8 @@ import { doToast } from 'redux/actions/notifications';
|
|||
import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
|
||||
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectListShuffle, makeSelectFileRenderModeForUri } from 'redux/selectors/content';
|
||||
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
|
||||
import { selectListShuffleForId, makeSelectFileRenderModeForUri } from 'redux/selectors/content';
|
||||
import { doToggleShuffleList, doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
|
||||
import { isStreamPlaceholderClaim } from 'util/claim';
|
||||
import * as RENDER_MODES from 'constants/file_render_modes';
|
||||
import ClaimPreview from './view';
|
||||
|
@ -46,11 +47,10 @@ const select = (state, props) => {
|
|||
const contentSigningChannel = contentClaim && contentClaim.signing_channel;
|
||||
const contentPermanentUri = contentClaim && contentClaim.permanent_url;
|
||||
const contentChannelUri = (contentSigningChannel && contentSigningChannel.permanent_url) || contentPermanentUri;
|
||||
const shuffleList = selectListShuffle(state);
|
||||
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
|
||||
const playNextUri = shuffle && shuffle[0];
|
||||
const collectionShuffle = selectListShuffleForId(state, collectionId);
|
||||
const playNextUri = collectionShuffle && collectionShuffle.newUrls[0];
|
||||
const lastUsedCollectionId = selectLastUsedCollection(state);
|
||||
const lastUsedCollection = makeSelectCollectionForId(lastUsedCollectionId)(state);
|
||||
const lastUsedCollection = lastUsedCollectionId && selectCollectionForId(state, lastUsedCollectionId);
|
||||
const isLivestreamClaim = isStreamPlaceholderClaim(claim);
|
||||
const permanentUrl = (claim && claim.permanent_url) || '';
|
||||
const isPostClaim = makeSelectFileRenderModeForUri(permanentUrl)(state) === RENDER_MODES.MARKDOWN;
|
||||
|
@ -64,34 +64,30 @@ const select = (state, props) => {
|
|||
isLivestreamClaim,
|
||||
isPostClaim,
|
||||
claimIsMine: selectClaimIsMine(state, claim),
|
||||
hasClaimInWatchLater: makeSelectCollectionForIdHasClaimUrl(
|
||||
hasClaimInWatchLater: selectCollectionForIdHasClaimUrl(
|
||||
state,
|
||||
COLLECTIONS_CONSTS.WATCH_LATER_ID,
|
||||
contentPermanentUri
|
||||
)(state),
|
||||
hasClaimInFavorites: makeSelectCollectionForIdHasClaimUrl(
|
||||
COLLECTIONS_CONSTS.FAVORITES_ID,
|
||||
contentPermanentUri
|
||||
)(state),
|
||||
),
|
||||
hasClaimInFavorites: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.FAVORITES_ID, contentPermanentUri),
|
||||
channelIsMuted: makeSelectChannelIsMuted(contentChannelUri)(state),
|
||||
channelIsBlocked: makeSelectChannelIsBlocked(contentChannelUri)(state),
|
||||
fileInfo: makeSelectFileInfoForUri(contentPermanentUri)(state),
|
||||
isSubscribed: selectIsSubscribedForUri(state, contentChannelUri),
|
||||
channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state),
|
||||
isAdmin: selectHasAdminChannel(state),
|
||||
claimInCollection: makeSelectCollectionForIdHasClaimUrl(collectionId, contentPermanentUri)(state),
|
||||
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
|
||||
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
|
||||
claimInCollection: selectCollectionForIdHasClaimUrl(state, collectionId, contentPermanentUri),
|
||||
isMyCollection: selectCollectionIsMine(state, collectionId),
|
||||
hasEdits: selectCollectionHasEditsForId(state, collectionId),
|
||||
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
|
||||
resolvedList: makeSelectUrlsForCollectionId(collectionId)(state),
|
||||
resolvedList: selectUrlsForCollectionId(state, collectionId),
|
||||
playNextUri,
|
||||
lastUsedCollection,
|
||||
hasClaimInLastUsedCollection: makeSelectCollectionForIdHasClaimUrl(
|
||||
lastUsedCollectionId,
|
||||
contentPermanentUri
|
||||
)(state),
|
||||
hasClaimInLastUsedCollection: selectCollectionForIdHasClaimUrl(state, lastUsedCollectionId, contentPermanentUri),
|
||||
lastUsedCollectionIsNotBuiltin:
|
||||
lastUsedCollectionId !== COLLECTIONS_CONSTS.WATCH_LATER_ID &&
|
||||
lastUsedCollectionId !== COLLECTIONS_CONSTS.FAVORITES_ID,
|
||||
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -108,13 +104,10 @@ const perform = (dispatch) => ({
|
|||
dispatch(doCommentModUnBlockAsAdmin(commenterUri, blockerId)),
|
||||
doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)),
|
||||
doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)),
|
||||
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),
|
||||
fetchCollectionItems: (collectionId) => dispatch(doFetchItemsInCollection({ collectionId })),
|
||||
doToggleShuffleList: (collectionId) => {
|
||||
dispatch(doToggleLoopList(collectionId, false, true));
|
||||
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
|
||||
},
|
||||
doToggleShuffleList: (params) => dispatch(doToggleShuffleList(params)),
|
||||
doRemovePersonalRecommendation: (uri) => dispatch(doRemovePersonalRecommendation(uri)),
|
||||
doPlaylistAddAndAllowPlaying: (params) => dispatch(doPlaylistAddAndAllowPlaying(params)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(ClaimPreview);
|
||||
|
|
|
@ -7,6 +7,7 @@ import * as COLLECTIONS_CONSTS from 'constants/collections';
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
|
||||
import { PUBLISH_PAGE, EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
|
||||
import Icon from 'component/common/icon';
|
||||
import {
|
||||
generateShareUrl,
|
||||
|
@ -17,10 +18,9 @@ import {
|
|||
} from 'util/url';
|
||||
import { useHistory } from 'react-router';
|
||||
import { buildURI, parseURI } from 'util/lbryURI';
|
||||
import ButtonAddToQueue from 'component/buttonAddToQueue';
|
||||
|
||||
const SHARE_DOMAIN = SHARE_DOMAIN_URL || URL;
|
||||
const PAGE_VIEW_QUERY = 'view';
|
||||
const EDIT_PAGE = 'edit';
|
||||
|
||||
type SubscriptionArgs = {
|
||||
channelName: string,
|
||||
|
@ -47,7 +47,6 @@ type Props = {
|
|||
doCommentModUnBlock: (string) => void,
|
||||
doCommentModBlockAsAdmin: (commenterUri: string, offendingCommentId: ?string, blockerId: ?string) => void,
|
||||
doCommentModUnBlockAsAdmin: (string, string) => void,
|
||||
doCollectionEdit: (string, any) => void,
|
||||
hasClaimInWatchLater: boolean,
|
||||
hasClaimInFavorites: boolean,
|
||||
claimInCollection: boolean,
|
||||
|
@ -56,7 +55,7 @@ type Props = {
|
|||
isLivestreamClaim?: boolean,
|
||||
isPostClaim?: boolean,
|
||||
fypId?: string,
|
||||
doToast: ({ message: string, isError?: boolean }) => void,
|
||||
doToast: ({ message: string, isError?: boolean, linkText?: string, linkTarget?: string }) => void,
|
||||
claimIsMine: boolean,
|
||||
fileInfo: FileListItem,
|
||||
prepareEdit: ({}, string, string) => void,
|
||||
|
@ -64,16 +63,23 @@ type Props = {
|
|||
doChannelSubscribe: (SubscriptionArgs) => void,
|
||||
doChannelUnsubscribe: (SubscriptionArgs) => void,
|
||||
isChannelPage: boolean,
|
||||
editedCollection: Collection,
|
||||
hasEdits: Collection,
|
||||
isAuthenticated: boolean,
|
||||
playNextUri: string,
|
||||
resolvedList: boolean,
|
||||
fetchCollectionItems: (string) => void,
|
||||
doToggleShuffleList: (string) => void,
|
||||
doToggleShuffleList: (params: { currentUri?: string, collectionId: string, hideToast?: boolean }) => void,
|
||||
lastUsedCollection: ?Collection,
|
||||
hasClaimInLastUsedCollection: boolean,
|
||||
lastUsedCollectionIsNotBuiltin: boolean,
|
||||
doRemovePersonalRecommendation: (uri: string) => void,
|
||||
collectionEmpty: boolean,
|
||||
doPlaylistAddAndAllowPlaying: (params: {
|
||||
uri: string,
|
||||
collectionName: string,
|
||||
collectionId: string,
|
||||
push: (uri: string) => void,
|
||||
}) => void,
|
||||
};
|
||||
|
||||
function ClaimMenuList(props: Props) {
|
||||
|
@ -96,7 +102,6 @@ function ClaimMenuList(props: Props) {
|
|||
doCommentModUnBlock,
|
||||
doCommentModBlockAsAdmin,
|
||||
doCommentModUnBlockAsAdmin,
|
||||
doCollectionEdit,
|
||||
hasClaimInWatchLater,
|
||||
hasClaimInFavorites,
|
||||
collectionId,
|
||||
|
@ -112,7 +117,7 @@ function ClaimMenuList(props: Props) {
|
|||
doChannelSubscribe,
|
||||
doChannelUnsubscribe,
|
||||
isChannelPage = false,
|
||||
editedCollection,
|
||||
hasEdits,
|
||||
isAuthenticated,
|
||||
playNextUri,
|
||||
resolvedList,
|
||||
|
@ -122,8 +127,16 @@ function ClaimMenuList(props: Props) {
|
|||
hasClaimInLastUsedCollection,
|
||||
lastUsedCollectionIsNotBuiltin,
|
||||
doRemovePersonalRecommendation,
|
||||
collectionEmpty,
|
||||
doPlaylistAddAndAllowPlaying,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
push,
|
||||
replace,
|
||||
location: { search },
|
||||
} = useHistory();
|
||||
|
||||
const [doShuffle, setDoShuffle] = React.useState(false);
|
||||
const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@');
|
||||
const isChannel = !incognitoClaim && !contentSigningChannel;
|
||||
|
@ -137,7 +150,6 @@ function ClaimMenuList(props: Props) {
|
|||
? __('Unfollow')
|
||||
: __('Follow');
|
||||
|
||||
const { push, replace } = useHistory();
|
||||
const claimType = isLivestreamClaim ? 'livestream' : isPostClaim ? 'post' : 'upload';
|
||||
|
||||
const fetchItems = React.useCallback(() => {
|
||||
|
@ -148,7 +160,7 @@ function ClaimMenuList(props: Props) {
|
|||
|
||||
React.useEffect(() => {
|
||||
if (doShuffle && resolvedList) {
|
||||
doToggleShuffleList(collectionId);
|
||||
doToggleShuffleList({ collectionId });
|
||||
if (playNextUri) {
|
||||
const navigateUrl = formatLbryUrlForWeb(playNextUri);
|
||||
push({
|
||||
|
@ -178,12 +190,24 @@ function ClaimMenuList(props: Props) {
|
|||
// $FlowFixMe
|
||||
(contentClaim.value.stream_type === 'audio' || contentClaim.value.stream_type === 'video');
|
||||
|
||||
function handleAdd(source, name, collectionId) {
|
||||
doToast({
|
||||
message: source ? __('Item removed from %name%', { name }) : __('Item added to %name%', { name }),
|
||||
function handleAdd(claimIsInPlaylist, name, collectionId) {
|
||||
const itemUrl = contentClaim?.canonical_url;
|
||||
|
||||
if (itemUrl) {
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
|
||||
|
||||
doPlaylistAddAndAllowPlaying({
|
||||
uri: itemUrl,
|
||||
collectionName: name,
|
||||
collectionId,
|
||||
push: (pushUri) =>
|
||||
push({
|
||||
pathname: formatLbryUrlForWeb(pushUri),
|
||||
search: urlParams.toString(),
|
||||
state: { collectionId, forceAutoplay: true },
|
||||
}),
|
||||
});
|
||||
if (contentClaim) {
|
||||
doCollectionEdit(collectionId, { uris: [contentClaim.permanent_url], remove: source, type: 'playlist' });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,7 +254,7 @@ function ClaimMenuList(props: Props) {
|
|||
prepareEdit(claim, editUri, claimType);
|
||||
} else {
|
||||
const channelUrl = claim.name + ':' + claim.claim_id;
|
||||
push(`/${channelUrl}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`);
|
||||
push(`/${channelUrl}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -278,7 +302,6 @@ function ClaimMenuList(props: Props) {
|
|||
push(`/$/${PAGES.REPORT_CONTENT}?claimId=${contentClaim && contentClaim.claim_id}`);
|
||||
}
|
||||
|
||||
const shouldShow = !IS_WEB || (IS_WEB && isAuthenticated);
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
|
@ -308,12 +331,13 @@ function ClaimMenuList(props: Props) {
|
|||
{/* COLLECTION OPERATIONS */}
|
||||
{collectionId && isCollectionClaim ? (
|
||||
<>
|
||||
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
|
||||
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
|
||||
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}`)}>
|
||||
<a className="menu__link" href={`/$/${PAGES.PLAYLIST}/${collectionId}`}>
|
||||
<Icon aria-hidden icon={ICONS.VIEW} />
|
||||
{__('View List')}
|
||||
{__('View Playlist')}
|
||||
</a>
|
||||
</MenuItem>
|
||||
{!collectionEmpty && (
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => {
|
||||
|
@ -326,15 +350,27 @@ function ClaimMenuList(props: Props) {
|
|||
{__('Shuffle Play')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMyCollection && (
|
||||
<>
|
||||
{!collectionEmpty && (
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
|
||||
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`)}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
|
||||
{editedCollection ? __('Publish') : __('Edit List')}
|
||||
{hasEdits ? __('Publish') : __('Update')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.EDIT} />
|
||||
{__('Edit')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
|
@ -343,20 +379,26 @@ function ClaimMenuList(props: Props) {
|
|||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.DELETE} />
|
||||
{__('Delete List')}
|
||||
{__('Delete Playlist')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
shouldShow &&
|
||||
isPlayable && (
|
||||
<>
|
||||
{/* QUEUE */}
|
||||
{contentClaim && <ButtonAddToQueue uri={contentClaim.permanent_url} menuItem />}
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{/* WATCH LATER */}
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)}
|
||||
onSelect={() =>
|
||||
handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)
|
||||
}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
|
||||
|
@ -379,8 +421,8 @@ function ClaimMenuList(props: Props) {
|
|||
onSelect={() => openModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.STACK} />
|
||||
{__('Add to Lists')}
|
||||
<Icon aria-hidden icon={ICONS.PLAYLIST_ADD} />
|
||||
{__('Add to Playlist')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{lastUsedCollection && lastUsedCollectionIsNotBuiltin && (
|
||||
|
@ -395,17 +437,20 @@ function ClaimMenuList(props: Props) {
|
|||
{hasClaimInLastUsedCollection && <Icon aria-hidden icon={ICONS.DELETE} />}
|
||||
{!hasClaimInLastUsedCollection &&
|
||||
__('Add to %collection%', { collection: lastUsedCollection.name })}
|
||||
{hasClaimInLastUsedCollection && __('In %collection%', { collection: lastUsedCollection.name })}
|
||||
{hasClaimInLastUsedCollection &&
|
||||
__('In %collection%', { collection: lastUsedCollection.name })}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
<hr className="menu__separator" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
|
||||
{shouldShow && (
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{!isChannelPage && (
|
||||
<>
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
selectGeoRestrictionForUri,
|
||||
} from 'redux/selectors/claims';
|
||||
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
|
||||
import { makeSelectCollectionIsMine } from 'redux/selectors/collections';
|
||||
import { selectCollectionIsMine } from 'redux/selectors/collections';
|
||||
|
||||
import { doResolveUri } from 'redux/actions/claims';
|
||||
import { doFileGet } from 'redux/actions/file';
|
||||
|
@ -22,7 +22,7 @@ import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
|
|||
import { isClaimNsfw, isStreamPlaceholderClaim } from 'util/claim';
|
||||
import ClaimPreview from './view';
|
||||
import formatMediaDuration from 'util/formatMediaDuration';
|
||||
import { doClearContentHistoryUri } from 'redux/actions/content';
|
||||
import { doClearContentHistoryUri, doUriInitiatePlay } from 'redux/actions/content';
|
||||
|
||||
const select = (state, props) => {
|
||||
const claim = props.uri && selectClaimForUri(state, props.uri);
|
||||
|
@ -50,7 +50,7 @@ const select = (state, props) => {
|
|||
isLivestream,
|
||||
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
|
||||
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
|
||||
isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state),
|
||||
isCollectionMine: selectCollectionIsMine(state, props.collectionId),
|
||||
lang: selectLanguage(state),
|
||||
};
|
||||
};
|
||||
|
@ -59,6 +59,8 @@ const perform = (dispatch) => ({
|
|||
resolveUri: (uri) => dispatch(doResolveUri(uri)),
|
||||
getFile: (uri) => dispatch(doFileGet(uri, false)),
|
||||
doClearContentHistoryUri: (uri) => dispatch(doClearContentHistoryUri(uri)),
|
||||
doUriInitiatePlay: (playingOptions, isPlayable, isFloating) =>
|
||||
dispatch(doUriInitiatePlay(playingOptions, isPlayable, isFloating)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(ClaimPreview);
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import ButtonAddToQueue from './view';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
|
||||
const perform = {
|
||||
doToast,
|
||||
doCollectionEdit,
|
||||
};
|
||||
|
||||
export default connect(null, perform)(ButtonAddToQueue);
|
|
@ -0,0 +1,40 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
collectionId: string,
|
||||
focusable: boolean,
|
||||
// -- redux --
|
||||
doToast: (props: { message: string }) => void,
|
||||
doCollectionEdit: (id: string, any) => void,
|
||||
};
|
||||
|
||||
function ButtonAddToQueue(props: Props) {
|
||||
const { uri, collectionId, focusable = true, doToast, doCollectionEdit } = props;
|
||||
|
||||
function handleRemove(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
doToast({ message: __('Item removed') });
|
||||
|
||||
doCollectionEdit(collectionId, { uris: [uri], remove: true, type: 'playlist' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="claim-preview__hover-actions third-item">
|
||||
<Button
|
||||
title={__('Remove')}
|
||||
label={__('Remove')}
|
||||
className="button--file-action"
|
||||
icon={ICONS.DELETE}
|
||||
onClick={(e) => handleRemove(e)}
|
||||
tabIndex={focusable ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ButtonAddToQueue;
|
|
@ -2,20 +2,25 @@
|
|||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import Empty from 'component/common/empty';
|
||||
import ButtonRemoveFromCollection from './buttonRemoveFromCollection';
|
||||
|
||||
type Props = {
|
||||
uri?: string,
|
||||
collectionId?: ?string,
|
||||
isChannel: boolean,
|
||||
type: string,
|
||||
message: string,
|
||||
};
|
||||
|
||||
function ClaimPreviewHidden(props: Props) {
|
||||
const { isChannel, type, message } = props;
|
||||
const { uri, collectionId, isChannel, type, message } = props;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classnames('claim-preview__wrapper', {
|
||||
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
|
||||
'claim-preview__wrapper--inline': type === 'inline',
|
||||
'claim-preview__wrapper--small': type === 'small',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
@ -23,7 +28,14 @@ function ClaimPreviewHidden(props: Props) {
|
|||
'claim-preview--large': type === 'large',
|
||||
})}
|
||||
>
|
||||
<div className="media__thumb" />
|
||||
<div className={classnames('media__thumb', { 'media__thumb--small': type === 'small' })}>
|
||||
{collectionId && (
|
||||
<div className="claim-preview__hover-actions-grid">
|
||||
<ButtonRemoveFromCollection uri={uri} collectionId={collectionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Empty text={message} />
|
||||
</div>
|
||||
</li>
|
|
@ -25,18 +25,19 @@ import useGetThumbnail from 'effects/use-get-thumbnail';
|
|||
import ClaimPreviewTitle from 'component/claimPreviewTitle';
|
||||
import ClaimPreviewSubtitle from 'component/claimPreviewSubtitle';
|
||||
import ClaimRepostAuthor from 'component/claimRepostAuthor';
|
||||
import FileDownloadLink from 'component/fileDownloadLink';
|
||||
import FileWatchLaterLink from 'component/fileWatchLaterLink';
|
||||
import PublishPending from 'component/publish/shared/publishPending';
|
||||
import ButtonAddToQueue from 'component/buttonAddToQueue';
|
||||
import ClaimMenuList from 'component/claimMenuList';
|
||||
import ClaimPreviewReset from 'component/claimPreviewReset';
|
||||
import ClaimPreviewLoading from './claim-preview-loading';
|
||||
import ClaimPreviewHidden from './claim-preview-no-mature';
|
||||
import ClaimPreviewNoContent from './claim-preview-no-content';
|
||||
import ClaimPreviewLoading from 'component/common/claim-preview-loading';
|
||||
import ClaimPreviewHidden from './internal/claim-preview-no-mature';
|
||||
import ClaimPreviewNoContent from './internal/claim-preview-no-content';
|
||||
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import CollectionEditButtons from 'component/collectionEditButtons';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
|
||||
|
||||
const AbandonedChannelPreview = lazyImport(() =>
|
||||
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
|
||||
|
@ -54,7 +55,7 @@ type Props = {
|
|||
reflectingProgress?: any, // fxme
|
||||
resolveUri: (string) => void,
|
||||
isResolvingUri: boolean,
|
||||
history: { push: (string | any) => void },
|
||||
history: { push: (string | any) => void, location: { pathname: string, search: string } },
|
||||
title: string,
|
||||
nsfw: boolean,
|
||||
placeholder: string,
|
||||
|
@ -98,7 +99,13 @@ type Props = {
|
|||
unavailableUris?: Array<string>,
|
||||
showMemberBadge?: boolean,
|
||||
inWatchHistory?: boolean,
|
||||
smallThumbnail?: boolean,
|
||||
showIndexes?: boolean,
|
||||
playItemsOnClick?: boolean,
|
||||
disableClickNavigation?: boolean,
|
||||
doClearContentHistoryUri: (uri: string) => void,
|
||||
doUriInitiatePlay: (playingOptions: PlayingUri, isPlayable?: boolean, isFloating?: boolean) => void,
|
||||
doDisablePlayerDrag?: (disable: boolean) => void,
|
||||
};
|
||||
|
||||
const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
||||
|
@ -166,11 +173,22 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
unavailableUris,
|
||||
showMemberBadge,
|
||||
inWatchHistory,
|
||||
smallThumbnail,
|
||||
showIndexes,
|
||||
playItemsOnClick,
|
||||
disableClickNavigation,
|
||||
doClearContentHistoryUri,
|
||||
doUriInitiatePlay,
|
||||
doDisablePlayerDrag,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
location: { pathname, search },
|
||||
} = history;
|
||||
|
||||
const playlistPreviewItem = unavailableUris !== undefined || showIndexes;
|
||||
const isCollection = claim && claim.value_type === 'collection';
|
||||
const collectionClaimId = isCollection && claim && claim.claim_id;
|
||||
const listId = collectionId || collectionClaimId;
|
||||
|
@ -182,7 +200,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
if (isMyCollection && claim === null && unavailableUris) unavailableUris.push(uri);
|
||||
|
||||
const shouldHideActions = hideActions || isMyCollection || type === 'small' || type === 'tooltip';
|
||||
const canonicalUrl = claim && claim.canonical_url;
|
||||
const channelSubscribers = React.useMemo(() => {
|
||||
if (channelSubCount === undefined) {
|
||||
return <span />;
|
||||
|
@ -244,6 +261,17 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
}
|
||||
|
||||
const handleNavLinkClick = (e) => {
|
||||
if (playItemsOnClick && claim) {
|
||||
doUriInitiatePlay(
|
||||
{
|
||||
uri: claim?.canonical_url || uri,
|
||||
collection: { collectionId },
|
||||
source: collectionId === 'queue' ? collectionId : undefined,
|
||||
},
|
||||
true,
|
||||
disableClickNavigation
|
||||
);
|
||||
}
|
||||
if (onClick) {
|
||||
onClick(e, claim, indexInContainer); // not sure indexInContainer is used for anything.
|
||||
}
|
||||
|
@ -252,8 +280,8 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
|
||||
const navLinkProps = {
|
||||
to: {
|
||||
pathname: navigateUrl,
|
||||
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
|
||||
pathname: disableClickNavigation ? pathname : navigateUrl,
|
||||
search: disableClickNavigation ? search : navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
|
||||
},
|
||||
onClick: handleNavLinkClick,
|
||||
onAuxClick: handleNavLinkClick,
|
||||
|
@ -301,12 +329,24 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
onClick(e, claim, indexInContainer);
|
||||
}
|
||||
|
||||
if (claim && !pending && !disableNavigation) {
|
||||
if (claim && !pending && !disableNavigation && !disableClickNavigation) {
|
||||
history.push({
|
||||
pathname: navigateUrl,
|
||||
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
|
||||
});
|
||||
}
|
||||
|
||||
if (playItemsOnClick && claim) {
|
||||
doUriInitiatePlay(
|
||||
{
|
||||
uri: claim?.canonical_url || uri,
|
||||
collection: { collectionId },
|
||||
source: collectionId === 'queue' ? collectionId : undefined,
|
||||
},
|
||||
true,
|
||||
disableClickNavigation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromHistory(e, uri) {
|
||||
|
@ -323,7 +363,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
// **************************************************************************
|
||||
// **************************************************************************
|
||||
|
||||
if ((shouldHide && !showNullPlaceholder) || (isLivestream && !ENABLE_NO_SOURCE_CLAIMS)) {
|
||||
if (!playlistPreviewItem && ((shouldHide && !showNullPlaceholder) || (isLivestream && !ENABLE_NO_SOURCE_CLAIMS))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -332,7 +372,14 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
}
|
||||
|
||||
if (placeholder === 'loading' || (uri && !claim && isResolvingUri)) {
|
||||
return <ClaimPreviewLoading isChannel={isChannelUri} type={type} />;
|
||||
return (
|
||||
<ClaimPreviewLoading
|
||||
isChannel={isChannelUri}
|
||||
type={type}
|
||||
WrapperElement={WrapperElement}
|
||||
xsmall={smallThumbnail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (claim && showNullPlaceholder && shouldHide && nsfw && obscureNsfw) {
|
||||
|
@ -345,8 +392,16 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (claim && showNullPlaceholder && shouldHide) {
|
||||
return <ClaimPreviewHidden message={__('This content is hidden')} isChannel={isChannelUri} type={type} />;
|
||||
if ((claim && showNullPlaceholder && shouldHide) || (!claim && playlistPreviewItem)) {
|
||||
return (
|
||||
<ClaimPreviewHidden
|
||||
message={!claim && playlistPreviewItem ? __('Deleted content') : __('This content is hidden')}
|
||||
isChannel={isChannelUri}
|
||||
type={type}
|
||||
uri={uri}
|
||||
collectionId={!claim && playlistPreviewItem && collectionId ? collectionId : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!claim && (showNullPlaceholder || empty)) {
|
||||
|
@ -394,6 +449,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
>
|
||||
<>
|
||||
{!hideRepostLabel && <ClaimRepostAuthor uri={uri} />}
|
||||
|
||||
<div
|
||||
className={classnames('claim-preview', {
|
||||
'claim-preview--small': type === 'small' || type === 'tooltip',
|
||||
|
@ -403,12 +459,23 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
'claim-preview--channel': isChannelUri,
|
||||
'claim-preview--visited': !isChannelUri && !claimIsMine && hasVisitedUri,
|
||||
'claim-preview--pending': pending,
|
||||
'claim-preview--collection-mine': isMyCollection && showEdit,
|
||||
'claim-preview--collection-editing': isMyCollection && showEdit,
|
||||
'swipe-list__item': swipeLayout,
|
||||
})}
|
||||
>
|
||||
{showIndexes && (
|
||||
<span className="card__subtitle card__subtitle--small-no-margin claim-preview__list-index">
|
||||
{indexInContainer + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isMyCollection && showEdit && (
|
||||
<CollectionEditButtons uri={uri} collectionId={listId} dragHandleProps={dragHandleProps} />
|
||||
<CollectionEditButtons
|
||||
uri={uri}
|
||||
collectionId={listId}
|
||||
dragHandleProps={dragHandleProps}
|
||||
doDisablePlayerDrag={doDisablePlayerDrag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isChannelUri && claim ? (
|
||||
|
@ -424,22 +491,24 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
<>
|
||||
{!pending ? (
|
||||
<NavLink aria-hidden tabIndex={-1} {...navLinkProps}>
|
||||
<FileThumbnail thumbnail={thumbnailUrl}>
|
||||
<div className="claim-preview__hover-actions">
|
||||
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />}
|
||||
<FileThumbnail thumbnail={thumbnailUrl} small={smallThumbnail}>
|
||||
{isPlayable && !smallThumbnail && (
|
||||
<div className="claim-preview__hover-actions-grid">
|
||||
<FileWatchLaterLink focusable={false} uri={repostedContentUri} />
|
||||
<ButtonAddToQueue focusable={false} uri={repostedContentUri} />
|
||||
</div>
|
||||
{/* @if TARGET='app' */}
|
||||
<div className="claim-preview__hover-actions">
|
||||
{claim && !isCollection && (
|
||||
<FileDownloadLink focusable={false} uri={canonicalUrl} hideOpenButton hideDownloadStatus />
|
||||
)}
|
||||
</div>
|
||||
{/* @endif */}
|
||||
{(!isLivestream || isLivestreamActive) && (
|
||||
<div className="claim-preview__file-property-overlay">
|
||||
<PreviewOverlayProperties uri={uri} small={type === 'small'} properties={liveProperty} />
|
||||
<PreviewOverlayProperties
|
||||
uri={uri}
|
||||
small={type === 'small'}
|
||||
xsmall={smallThumbnail}
|
||||
properties={liveProperty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCollection && <CollectionPreviewOverlay collectionId={listId} />}
|
||||
<ClaimPreviewProgress uri={uri} />
|
||||
</FileThumbnail>
|
||||
</NavLink>
|
||||
|
|
|
@ -76,10 +76,10 @@ function ClaimPreviewSubtitle(props: Props) {
|
|||
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? (
|
||||
<LivestreamDateTime uri={uri} />
|
||||
) : (
|
||||
<>
|
||||
<span className="claim-extra-info">
|
||||
<FileViewCountInline uri={uri} isLivestream={isLivestream} />
|
||||
<DateTime timeAgo uri={uri} />
|
||||
</>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -6,7 +6,9 @@ import {
|
|||
selectDateForUri,
|
||||
selectGeoRestrictionForUri,
|
||||
selectClaimIsMine,
|
||||
selectCanonicalUrlForUri,
|
||||
} from 'redux/selectors/claims';
|
||||
import { selectUrlsForCollectionId } from 'redux/selectors/collections';
|
||||
import { doFileGet } from 'redux/actions/file';
|
||||
import { doResolveUri } from 'redux/actions/claims';
|
||||
import { selectViewCountForUri, selectBanStateForUri } from 'lbryinc';
|
||||
|
@ -21,6 +23,10 @@ const select = (state, props) => {
|
|||
const media = claim && claim.value && (claim.value.video || claim.value.audio);
|
||||
const mediaDuration = media && media.duration && formatMediaDuration(media.duration, { screenReader: true });
|
||||
const isLivestream = isStreamPlaceholderClaim(claim);
|
||||
const isCollection = claim && claim.value_type === 'collection';
|
||||
const collectionClaimId = isCollection && claim && claim.claim_id;
|
||||
const collectionUrls = collectionClaimId && selectUrlsForCollectionId(state, collectionClaimId);
|
||||
const collectionFirstItem = collectionUrls && collectionUrls[0];
|
||||
|
||||
return {
|
||||
claim,
|
||||
|
@ -38,6 +44,7 @@ const select = (state, props) => {
|
|||
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
|
||||
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
|
||||
viewCount: selectViewCountForUri(state, props.uri),
|
||||
collectionFirstUrl: collectionFirstItem && selectCanonicalUrlForUri(state, collectionFirstItem),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -17,9 +17,9 @@ import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
|
|||
import { formatClaimPreviewTitle } from 'util/formatAriaLabel';
|
||||
import { parseURI } from 'util/lbryURI';
|
||||
import PreviewOverlayProperties from 'component/previewOverlayProperties';
|
||||
import FileDownloadLink from 'component/fileDownloadLink';
|
||||
import FileHideRecommendation from 'component/fileHideRecommendation';
|
||||
import FileWatchLaterLink from 'component/fileWatchLaterLink';
|
||||
import ButtonAddToQueue from 'component/buttonAddToQueue';
|
||||
import ClaimRepostAuthor from 'component/claimRepostAuthor';
|
||||
import ClaimMenuList from 'component/claimMenuList';
|
||||
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
|
||||
|
@ -59,6 +59,7 @@ type Props = {
|
|||
swipeLayout: boolean,
|
||||
onHidden?: (string) => void,
|
||||
pulse?: boolean,
|
||||
collectionFirstUrl: ?string,
|
||||
};
|
||||
|
||||
// preview image cards used in related video functionality, channel overview page and homepage
|
||||
|
@ -94,6 +95,7 @@ function ClaimPreviewTile(props: Props) {
|
|||
swipeLayout = false,
|
||||
onHidden,
|
||||
pulse,
|
||||
collectionFirstUrl,
|
||||
} = props;
|
||||
const isRepost = claim && claim.repost_channel_url;
|
||||
const isCollection = claim && claim.value_type === 'collection';
|
||||
|
@ -111,7 +113,7 @@ function ClaimPreviewTile(props: Props) {
|
|||
const collectionClaimId = isCollection && claim && claim.claim_id;
|
||||
const shouldFetch = claim === undefined;
|
||||
const thumbnailUrl = useGetThumbnail(uri, claim, streamingUrl, getFile, placeholder) || thumbnail;
|
||||
const canonicalUrl = claim && claim.canonical_url;
|
||||
const canonicalUrl = claim && ((isCollection && collectionFirstUrl) || claim.canonical_url);
|
||||
const repostedContentUri = claim && (claim.reposted_claim ? claim.reposted_claim.permanent_url : claim.permanent_url);
|
||||
const listId = collectionId || collectionClaimId;
|
||||
const navigateUrl =
|
||||
|
@ -254,32 +256,30 @@ function ClaimPreviewTile(props: Props) {
|
|||
<FileThumbnail thumbnail={thumbnailUrl} allowGifs tileLayout>
|
||||
{!isChannel && (
|
||||
<React.Fragment>
|
||||
{((fypId && isStream) || isPlayable) && (
|
||||
<div className="claim-preview__hover-actions-grid">
|
||||
{fypId && isStream && (
|
||||
<div className="claim-preview__hover-actions">
|
||||
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />}
|
||||
</div>
|
||||
{fypId && (
|
||||
<div className="claim-preview__hover-actions">
|
||||
{isStream && <FileHideRecommendation focusable={false} uri={repostedContentUri} />}
|
||||
<FileHideRecommendation focusable={false} uri={repostedContentUri} />
|
||||
</div>
|
||||
)}
|
||||
{/* @if TARGET='app' */}
|
||||
<div className="claim-preview__hover-actions">
|
||||
{isStream && <FileDownloadLink focusable={false} uri={canonicalUrl} hideOpenButton />}
|
||||
|
||||
{isPlayable && (
|
||||
<>
|
||||
<FileWatchLaterLink focusable={false} uri={repostedContentUri} />
|
||||
<ButtonAddToQueue focusable={false} uri={repostedContentUri} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* @endif */}
|
||||
)}
|
||||
|
||||
<div className="claim-preview__file-property-overlay">
|
||||
<PreviewOverlayProperties uri={uri} properties={liveProperty || properties} />
|
||||
</div>
|
||||
<ClaimPreviewProgress uri={uri} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
{isCollection && (
|
||||
<React.Fragment>
|
||||
<div className="claim-preview__collection-wrapper">
|
||||
<CollectionPreviewOverlay collectionId={listId} uri={uri} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{isCollection && <CollectionPreviewOverlay collectionId={listId} />}
|
||||
</FileThumbnail>
|
||||
</NavLink>
|
||||
<div className="claim-tile__header">
|
||||
|
|
18
ui/component/claimRepostButton/index.js
Normal file
18
ui/component/claimRepostButton/index.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { selectClaimRepostedAmountForUri } from 'redux/selectors/claims';
|
||||
import ClaimRepostButton from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
return {
|
||||
repostedAmount: selectClaimRepostedAmountForUri(state, uri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(ClaimRepostButton);
|
28
ui/component/claimRepostButton/view.jsx
Normal file
28
ui/component/claimRepostButton/view.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
// @flow
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
// redux
|
||||
repostedAmount: number,
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
function ClaimRepostButton(props: Props) {
|
||||
const { uri, repostedAmount, doOpenModal } = props;
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
title={__('Repost this content')}
|
||||
label={repostedAmount > 1 ? __(`%repost_total% Reposts`, { repost_total: repostedAmount }) : __('Repost')}
|
||||
icon={ICONS.REPOST}
|
||||
requiresChannel
|
||||
onClick={() => doOpenModal(MODALS.REPOST, { uri })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimRepostButton;
|
9
ui/component/claimShareButton/index.js
Normal file
9
ui/component/claimShareButton/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import ClaimShareButton from './view';
|
||||
|
||||
const perform = {
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default connect(null, perform)(ClaimShareButton);
|
30
ui/component/claimShareButton/view.jsx
Normal file
30
ui/component/claimShareButton/view.jsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
// @flow
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
fileAction?: boolean,
|
||||
webShareable: boolean,
|
||||
collectionId?: string,
|
||||
// redux
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
function ClaimShareButton(props: Props) {
|
||||
const { uri, fileAction, collectionId, webShareable, doOpenModal } = props;
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
title={__('Share this content')}
|
||||
label={__('Share')}
|
||||
icon={ICONS.SHARE}
|
||||
onClick={() => doOpenModal(MODALS.SOCIAL_SHARE, { uri, webShareable, collectionId })}
|
||||
noStyle={!fileAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimShareButton;
|
|
@ -2,9 +2,7 @@
|
|||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Button from 'component/button';
|
||||
import Tooltip from 'component/common/tooltip';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
|
@ -19,6 +17,8 @@ type Props = {
|
|||
export default function ClaimSupportButton(props: Props) {
|
||||
const { uri, fileAction, isRepost, disableSupport, doOpenModal, preferredCurrency } = props;
|
||||
|
||||
if (disableSupport) return null;
|
||||
|
||||
const currencyToUse = preferredCurrency;
|
||||
|
||||
const iconToUse = {
|
||||
|
@ -32,17 +32,14 @@ export default function ClaimSupportButton(props: Props) {
|
|||
},
|
||||
};
|
||||
|
||||
return disableSupport ? null : (
|
||||
<Tooltip title={__('Support this claim')} arrow={false}>
|
||||
<Button
|
||||
button={!fileAction ? 'alt' : undefined}
|
||||
className={classnames('support-claim-button', { 'button--file-action': fileAction })}
|
||||
return (
|
||||
<FileActionButton
|
||||
title={__('Support this content')}
|
||||
label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')}
|
||||
icon={iconToUse[currencyToUse].icon}
|
||||
iconSize={iconToUse[currencyToUse].iconSize}
|
||||
label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')}
|
||||
requiresAuth
|
||||
onClick={() => doOpenModal(MODALS.SEND_TIP, { uri, isSupport: true })}
|
||||
noStyle={!fileAction}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ function ClaimType(props: Props) {
|
|||
const size = small ? COL.ICON_SIZE : undefined;
|
||||
|
||||
if (claimType === 'collection') {
|
||||
return <Icon size={size} icon={ICONS.STACK} />;
|
||||
return <Icon size={size} icon={ICONS.PLAYLIST} />;
|
||||
} else if (claimType === 'channel') {
|
||||
return <Icon size={size} icon={ICONS.CHANNEL} />;
|
||||
} else if (claimType === 'repost') {
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri, makeSelectClaimIsPending } from 'redux/selectors/claims';
|
||||
import { makeSelectCollectionIsMine, makeSelectEditedCollectionForId } from 'redux/selectors/collections';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { selectListShuffle } from 'redux/selectors/content';
|
||||
import { doToggleShuffleList, doToggleLoopList } from 'redux/actions/content';
|
||||
import CollectionActions from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
let firstItem;
|
||||
const collectionUrls = props.collectionUrls;
|
||||
if (collectionUrls) {
|
||||
// this will help play the first valid claim in a list
|
||||
// in case the first urls have been deleted
|
||||
collectionUrls.map((url) => {
|
||||
const claim = makeSelectClaimForUri(url)(state);
|
||||
if (firstItem === undefined && claim) {
|
||||
firstItem = claim.permanent_url;
|
||||
}
|
||||
});
|
||||
}
|
||||
const collectionId = props.collectionId;
|
||||
const shuffleList = selectListShuffle(state);
|
||||
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
|
||||
const playNextUri = shuffle && shuffle[0];
|
||||
|
||||
return {
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
claimIsPending: makeSelectClaimIsPending(props.uri)(state),
|
||||
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
|
||||
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(collectionId)(state)),
|
||||
firstItem,
|
||||
playNextUri,
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
doToggleShuffleList: (collectionId, shuffle) => {
|
||||
dispatch(doToggleLoopList(collectionId, false, true));
|
||||
dispatch(doToggleShuffleList(undefined, collectionId, shuffle, true));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CollectionActions);
|
|
@ -1,200 +0,0 @@
|
|||
// @flow
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import ClaimSupportButton from 'component/claimSupportButton';
|
||||
import FileReactions from 'component/fileReactions';
|
||||
import { useHistory } from 'react-router';
|
||||
import { EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
|
||||
import classnames from 'classnames';
|
||||
import { ENABLE_FILE_REACTIONS } from 'config';
|
||||
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: StreamClaim,
|
||||
openModal: (id: string, {}) => void,
|
||||
claimIsPending: boolean,
|
||||
isMyCollection: boolean,
|
||||
collectionId: string,
|
||||
showInfo: boolean,
|
||||
setShowInfo: (boolean) => void,
|
||||
showEdit: boolean,
|
||||
setShowEdit: (boolean) => void,
|
||||
collectionHasEdits: boolean,
|
||||
isBuiltin: boolean,
|
||||
doToggleShuffleList: (string, boolean) => void,
|
||||
playNextUri: string,
|
||||
firstItem: string,
|
||||
};
|
||||
|
||||
function CollectionActions(props: Props) {
|
||||
const {
|
||||
uri,
|
||||
openModal,
|
||||
claim,
|
||||
claimIsPending,
|
||||
isMyCollection,
|
||||
collectionId,
|
||||
showInfo,
|
||||
setShowInfo,
|
||||
collectionHasEdits,
|
||||
isBuiltin,
|
||||
doToggleShuffleList,
|
||||
playNextUri,
|
||||
firstItem,
|
||||
showEdit,
|
||||
setShowEdit,
|
||||
} = props;
|
||||
const [doShuffle, setDoShuffle] = React.useState(false);
|
||||
const { push } = useHistory();
|
||||
const isMobile = useIsMobile();
|
||||
const claimId = claim && claim.claim_id;
|
||||
const webShareable = true; // collections have cost?
|
||||
|
||||
const doPlay = React.useCallback(
|
||||
(playUri) => {
|
||||
const navigateUrl = formatLbryUrlForWeb(playUri);
|
||||
push({
|
||||
pathname: navigateUrl,
|
||||
search: generateListSearchUrlParams(collectionId),
|
||||
state: { forceAutoplay: true },
|
||||
});
|
||||
},
|
||||
[collectionId, push]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playNextUri && doShuffle) {
|
||||
setDoShuffle(false);
|
||||
doPlay(playNextUri);
|
||||
}
|
||||
}, [doPlay, doShuffle, playNextUri]);
|
||||
|
||||
const lhsSection = (
|
||||
<>
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.PLAY}
|
||||
label={__('Play')}
|
||||
title={__('Play')}
|
||||
onClick={() => {
|
||||
doToggleShuffleList(collectionId, false);
|
||||
doPlay(firstItem);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.SHUFFLE}
|
||||
label={__('Shuffle Play')}
|
||||
title={__('Shuffle Play')}
|
||||
onClick={() => {
|
||||
doToggleShuffleList(collectionId, true);
|
||||
setDoShuffle(true);
|
||||
}}
|
||||
/>
|
||||
{!isBuiltin && (
|
||||
<>
|
||||
{ENABLE_FILE_REACTIONS && uri && <FileReactions uri={uri} />}
|
||||
{uri && <ClaimSupportButton uri={uri} fileAction />}
|
||||
{/* TODO Add ClaimRepostButton component */}
|
||||
{uri && (
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.SHARE}
|
||||
label={__('Share')}
|
||||
title={__('Share')}
|
||||
onClick={() => openModal(MODALS.SOCIAL_SHARE, { uri, webShareable })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const rhsSection = (
|
||||
<>
|
||||
{!isBuiltin &&
|
||||
(isMyCollection ? (
|
||||
<>
|
||||
<Button
|
||||
title={uri ? __('Update') : __('Publish')}
|
||||
label={uri ? __('Update') : __('Publish')}
|
||||
className={classnames('button--file-action')}
|
||||
onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
|
||||
icon={ICONS.PUBLISH}
|
||||
iconColor={collectionHasEdits && 'red'}
|
||||
iconSize={18}
|
||||
disabled={claimIsPending}
|
||||
/>
|
||||
<Button
|
||||
className={classnames('button--file-action')}
|
||||
title={__('Delete List')}
|
||||
onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })}
|
||||
icon={ICONS.DELETE}
|
||||
iconSize={18}
|
||||
description={__('Delete List')}
|
||||
disabled={claimIsPending}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
title={__('Report content')}
|
||||
className="button--file-action"
|
||||
icon={ICONS.REPORT}
|
||||
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const infoButtons = (
|
||||
<div className="section">
|
||||
{uri && (
|
||||
<Button
|
||||
title={__('Info')}
|
||||
className={classnames('button-toggle', {
|
||||
'button-toggle--active': showInfo,
|
||||
})}
|
||||
icon={ICONS.MORE}
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMyCollection && (
|
||||
<Button
|
||||
title={__('Edit')}
|
||||
className={classnames('button-toggle', { 'button-toggle--active': showEdit })}
|
||||
icon={ICONS.EDIT}
|
||||
onClick={() => setShowEdit(!showEdit)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="media__actions stretch">
|
||||
{lhsSection}
|
||||
{rhsSection}
|
||||
{infoButtons}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="media__subtitle--between">
|
||||
<div className="section__actions">
|
||||
{lhsSection}
|
||||
{rhsSection}
|
||||
</div>
|
||||
|
||||
{infoButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CollectionActions;
|
|
@ -1,38 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import CollectionContent from './view';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import {
|
||||
makeSelectUrlsForCollectionId,
|
||||
makeSelectNameForCollectionId,
|
||||
makeSelectCollectionForId,
|
||||
makeSelectCollectionIsMine,
|
||||
} from 'redux/selectors/collections';
|
||||
import { selectPlayingUri, selectListLoop, selectListShuffle } from 'redux/selectors/content';
|
||||
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri: playingUri } = selectPlayingUri(state);
|
||||
const { permanent_url: url } = selectClaimForUri(state, playingUri) || {};
|
||||
|
||||
const loopList = selectListLoop(state);
|
||||
const loop = loopList && loopList.collectionId === props.id && loopList.loop;
|
||||
const shuffleList = selectListShuffle(state);
|
||||
const shuffle = shuffleList && shuffleList.collectionId === props.id && shuffleList.newUrls;
|
||||
|
||||
return {
|
||||
url,
|
||||
collection: makeSelectCollectionForId(props.id)(state),
|
||||
collectionUrls: makeSelectUrlsForCollectionId(props.id)(state),
|
||||
collectionName: makeSelectNameForCollectionId(props.id)(state),
|
||||
isMyCollection: makeSelectCollectionIsMine(props.id)(state),
|
||||
loop,
|
||||
shuffle,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(select, {
|
||||
doToggleLoopList,
|
||||
doToggleShuffleList,
|
||||
doCollectionEdit,
|
||||
})(CollectionContent);
|
|
@ -1,133 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Card from 'component/common/card';
|
||||
import Button from 'component/button';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import Icon from 'component/common/icon';
|
||||
import * as ICONS from 'constants/icons';
|
||||
|
||||
// prettier-ignore
|
||||
const Lazy = {
|
||||
// $FlowFixMe
|
||||
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
|
||||
// $FlowFixMe
|
||||
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
id: string,
|
||||
url: string,
|
||||
isMyCollection: boolean,
|
||||
collectionUrls: Array<Claim>,
|
||||
collectionName: string,
|
||||
collection: any,
|
||||
loop: boolean,
|
||||
shuffle: boolean,
|
||||
doToggleLoopList: (string, boolean) => void,
|
||||
doToggleShuffleList: (string, string, boolean) => void,
|
||||
createUnpublishedCollection: (string, Array<any>, ?string) => void,
|
||||
doCollectionEdit: (string, CollectionEditParams) => void,
|
||||
};
|
||||
|
||||
export default function CollectionContent(props: Props) {
|
||||
const {
|
||||
isMyCollection,
|
||||
collectionUrls,
|
||||
collectionName,
|
||||
id,
|
||||
url,
|
||||
loop,
|
||||
shuffle,
|
||||
doToggleLoopList,
|
||||
doToggleShuffleList,
|
||||
doCollectionEdit,
|
||||
} = props;
|
||||
|
||||
const [showEdit, setShowEdit] = React.useState(false);
|
||||
|
||||
function handleOnDragEnd(result) {
|
||||
const { source, destination } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
const { index: from } = source;
|
||||
const { index: to } = destination;
|
||||
|
||||
doCollectionEdit(id, { order: { from, to } });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
isBodyList
|
||||
className="file-page__playlist-collection"
|
||||
title={
|
||||
<>
|
||||
<a href={`/$/${PAGES.LIST}/${id}`}>
|
||||
<span className="file-page__playlist-collection__row">
|
||||
<Icon
|
||||
icon={
|
||||
(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
|
||||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
|
||||
ICONS.STACK
|
||||
}
|
||||
className="icon--margin-right"
|
||||
/>
|
||||
{collectionName}
|
||||
</span>
|
||||
</a>
|
||||
<span className="file-page__playlist-collection__row">
|
||||
<Button
|
||||
button="alt"
|
||||
title={__('Loop')}
|
||||
icon={ICONS.REPEAT}
|
||||
iconColor={loop && 'blue'}
|
||||
className="button--file-action"
|
||||
onClick={() => doToggleLoopList(id, !loop)}
|
||||
/>
|
||||
<Button
|
||||
button="alt"
|
||||
title={__('Shuffle')}
|
||||
icon={ICONS.SHUFFLE}
|
||||
iconColor={shuffle && 'blue'}
|
||||
className="button--file-action"
|
||||
onClick={() => doToggleShuffleList(url, id, !shuffle)}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
titleActions={
|
||||
isMyCollection && (
|
||||
<Button
|
||||
title={__('Edit')}
|
||||
className={classnames('button-toggle', { 'button-toggle--active': showEdit })}
|
||||
icon={ICONS.EDIT}
|
||||
onClick={() => setShowEdit(!showEdit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
body={
|
||||
<React.Suspense fallback={null}>
|
||||
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Lazy.Droppable droppableId="list__ordering">
|
||||
{(DroppableProvided) => (
|
||||
<ClaimList
|
||||
isCardBody
|
||||
type="small"
|
||||
activeUri={url}
|
||||
uris={collectionUrls}
|
||||
collectionId={id}
|
||||
empty={__('List is Empty')}
|
||||
showEdit={showEdit}
|
||||
droppableProvided={DroppableProvided}
|
||||
/>
|
||||
)}
|
||||
</Lazy.Droppable>
|
||||
</Lazy.DragDropContext>
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
selectTitleForUri,
|
||||
selectThumbnailForUri,
|
||||
makeSelectMetadataItemForUri,
|
||||
makeSelectAmountForUri,
|
||||
makeSelectClaimForUri,
|
||||
selectUpdateCollectionError,
|
||||
selectUpdatingCollection,
|
||||
selectCreateCollectionError,
|
||||
selectCreatingCollection,
|
||||
} from 'redux/selectors/claims';
|
||||
import {
|
||||
makeSelectCollectionForId,
|
||||
makeSelectUrlsForCollectionId,
|
||||
makeSelectClaimIdsForCollectionId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { doCollectionPublish, doCollectionPublishUpdate } from 'redux/actions/claims';
|
||||
import { selectBalance } from 'redux/selectors/wallet';
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
|
||||
import CollectionForm from './view';
|
||||
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
title: selectTitleForUri(state, props.uri),
|
||||
thumbnailUrl: selectThumbnailForUri(state, props.uri),
|
||||
description: makeSelectMetadataItemForUri(props.uri, 'description')(state),
|
||||
tags: makeSelectMetadataItemForUri(props.uri, 'tags')(state),
|
||||
locations: makeSelectMetadataItemForUri(props.uri, 'locations')(state),
|
||||
languages: makeSelectMetadataItemForUri(props.uri, 'languages')(state),
|
||||
amount: makeSelectAmountForUri(props.uri)(state),
|
||||
updateError: selectUpdateCollectionError(state),
|
||||
updatingCollection: selectUpdatingCollection(state),
|
||||
createError: selectCreateCollectionError(state),
|
||||
creatingCollection: selectCreatingCollection(state),
|
||||
balance: selectBalance(state),
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
incognito: selectIncognito(state),
|
||||
collection: makeSelectCollectionForId(props.collectionId)(state),
|
||||
collectionUrls: makeSelectUrlsForCollectionId(props.collectionId)(state),
|
||||
collectionClaimIds: makeSelectClaimIdsForCollectionId(props.collectionId)(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch, ownProps) => ({
|
||||
publishCollectionUpdate: (params) => dispatch(doCollectionPublishUpdate(params)),
|
||||
publishCollection: (params, collectionId) => dispatch(doCollectionPublish(params, collectionId)),
|
||||
clearCollectionErrors: () => dispatch({ type: ACTIONS.CLEAR_COLLECTION_ERRORS }),
|
||||
doCollectionEdit: (params) => dispatch(doCollectionEdit(ownProps.collectionId, params)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CollectionForm);
|
|
@ -1,511 +0,0 @@
|
|||
// @flow
|
||||
import { DOMAIN } from 'config';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Button from 'component/button';
|
||||
import TagsSearch from 'component/tagsSearch';
|
||||
import ErrorText from 'component/common/error-text';
|
||||
import ClaimAbandonButton from 'component/claimAbandonButton';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Card from 'component/common/card';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import SelectThumbnail from 'component/selectThumbnail';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { isNameValid, regexInvalidURI } from 'util/lbryURI';
|
||||
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
|
||||
import { FormField } from 'component/common/form';
|
||||
import { handleBidChange } from 'util/publish';
|
||||
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
|
||||
import { INVALID_NAME_ERROR } from 'constants/claim';
|
||||
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import analytics from 'analytics';
|
||||
|
||||
// prettier-ignore
|
||||
const Lazy = {
|
||||
// $FlowFixMe
|
||||
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
|
||||
// $FlowFixMe
|
||||
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
|
||||
};
|
||||
|
||||
const LANG_NONE = 'none';
|
||||
const MAX_TAG_SELECT = 5;
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: CollectionClaim,
|
||||
balance: number,
|
||||
disabled: boolean,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
incognito: boolean,
|
||||
// params
|
||||
title: string,
|
||||
amount: number,
|
||||
thumbnailUrl: string,
|
||||
description: string,
|
||||
tags: Array<string>,
|
||||
locations: Array<string>,
|
||||
languages: Array<string>,
|
||||
collectionId: string,
|
||||
collection: Collection,
|
||||
collectionClaimIds: Array<string>,
|
||||
collectionUrls: Array<string>,
|
||||
updatingCollection: boolean,
|
||||
updateError: string,
|
||||
createError: string,
|
||||
creatingCollection: boolean,
|
||||
publishCollectionUpdate: (CollectionUpdateParams) => Promise<any>,
|
||||
publishCollection: (CollectionPublishParams, string) => Promise<any>,
|
||||
clearCollectionErrors: () => void,
|
||||
onDone: (string) => void,
|
||||
doCollectionEdit: (CollectionEditParams) => void,
|
||||
};
|
||||
|
||||
function CollectionForm(props: Props) {
|
||||
const {
|
||||
uri, // collection uri
|
||||
claim,
|
||||
balance,
|
||||
// publish params
|
||||
amount,
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
tags,
|
||||
locations,
|
||||
languages = [],
|
||||
// rest
|
||||
updateError,
|
||||
updatingCollection,
|
||||
creatingCollection,
|
||||
createError,
|
||||
disabled,
|
||||
activeChannelClaim,
|
||||
incognito,
|
||||
collectionId,
|
||||
collection,
|
||||
collectionUrls,
|
||||
collectionClaimIds,
|
||||
publishCollectionUpdate,
|
||||
publishCollection,
|
||||
clearCollectionErrors,
|
||||
onDone,
|
||||
doCollectionEdit,
|
||||
} = props;
|
||||
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
|
||||
let prefix = IS_WEB ? `${DOMAIN}/` : 'lbry://';
|
||||
if (activeChannelName && !incognito) {
|
||||
prefix += `${activeChannelName}/`;
|
||||
}
|
||||
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
|
||||
const collectionName = (claim && claim.name) || (collection && collection.name);
|
||||
const collectionChannel = claim && claim.signing_channel ? claim.signing_channel.claim_id : undefined;
|
||||
const hasClaim = !!claim;
|
||||
const [nameError, setNameError] = React.useState(undefined);
|
||||
const [bidError, setBidError] = React.useState('');
|
||||
const [thumbStatus, setThumbStatus] = React.useState('');
|
||||
const [thumbError, setThumbError] = React.useState('');
|
||||
const [params, setParams]: [any, (any) => void] = React.useState({});
|
||||
const name = params.name;
|
||||
const isNewCollection = !uri;
|
||||
const { replace } = useHistory();
|
||||
const languageParam = params.languages || [];
|
||||
const primaryLanguage = Array.isArray(languageParam) && languageParam.length && languageParam[0];
|
||||
const secondaryLanguage = Array.isArray(languageParam) && languageParam.length >= 2 && languageParam[1];
|
||||
const hasClaims = params.claims && params.claims.length;
|
||||
const collectionClaimIdsString = JSON.stringify(collectionClaimIds);
|
||||
const itemError = !hasClaims ? __('Cannot publish empty list') : '';
|
||||
const thumbnailError =
|
||||
(thumbError && thumbStatus !== THUMBNAIL_STATUSES.COMPLETE && __('Invalid thumbnail')) ||
|
||||
(thumbStatus === THUMBNAIL_STATUSES.IN_PROGRESS && __('Please wait for thumbnail to finish uploading'));
|
||||
const submitError = nameError || bidError || itemError || updateError || createError || thumbnailError;
|
||||
|
||||
function parseName(newName) {
|
||||
let INVALID_URI_CHARS = new RegExp(regexInvalidURI, 'gu');
|
||||
return newName.replace(INVALID_URI_CHARS, '-');
|
||||
}
|
||||
|
||||
function setParam(paramObj) {
|
||||
setParams({ ...params, ...paramObj });
|
||||
}
|
||||
|
||||
function updateParams(paramsObj) {
|
||||
setParams({ ...params, ...paramsObj });
|
||||
}
|
||||
|
||||
// TODO remove this or better decide whether app should delete languages[2+]
|
||||
// This was added because a previous update setting was duplicating language codes
|
||||
function dedupeLanguages(languages) {
|
||||
if (languages.length <= 1) {
|
||||
return languages;
|
||||
} else if (languages.length === 2) {
|
||||
if (languages[0] !== languages[1]) {
|
||||
return languages;
|
||||
} else {
|
||||
return [languages[0]];
|
||||
}
|
||||
} else if (languages.length > 2) {
|
||||
const newLangs = [];
|
||||
languages.forEach((l) => {
|
||||
if (!newLangs.includes(l)) {
|
||||
newLangs.push(l);
|
||||
}
|
||||
});
|
||||
return newLangs;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateThumbnail(update: { [string]: string }) {
|
||||
if (update.thumbnail_url) {
|
||||
setParam(update);
|
||||
} else if (update.thumbnail_status) {
|
||||
setThumbStatus(update.thumbnail_status);
|
||||
} else {
|
||||
setThumbError(update.thumbnail_error);
|
||||
}
|
||||
}
|
||||
|
||||
function getCollectionParams() {
|
||||
const collectionParams: {
|
||||
thumbnail_url?: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
title?: string,
|
||||
bid: string,
|
||||
languages?: ?Array<string>,
|
||||
locations?: ?Array<string>,
|
||||
tags?: ?Array<{ name: string }>,
|
||||
claim_id?: string,
|
||||
channel_id?: string,
|
||||
claims: ?Array<string>,
|
||||
} = {
|
||||
thumbnail_url: thumbnailUrl,
|
||||
name: parseName(collectionName),
|
||||
description,
|
||||
title: claim ? title : collectionName,
|
||||
bid: String(amount || 0.001),
|
||||
languages: languages ? dedupeLanguages(languages) : [],
|
||||
locations: locations || [],
|
||||
tags: tags
|
||||
? tags.map((tag) => {
|
||||
return { name: tag };
|
||||
})
|
||||
: [],
|
||||
claim_id: claim ? claim.claim_id : undefined,
|
||||
channel_id: claim ? collectionChannel : activeChannelId || undefined,
|
||||
claims: collectionClaimIds,
|
||||
};
|
||||
|
||||
return collectionParams;
|
||||
}
|
||||
|
||||
function handleOnDragEnd(result) {
|
||||
const { source, destination } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
const { index: from } = source;
|
||||
const { index: to } = destination;
|
||||
|
||||
doCollectionEdit({ order: { from, to } });
|
||||
}
|
||||
|
||||
function handleLanguageChange(index, code) {
|
||||
let langs = [...languageParam];
|
||||
if (index === 0) {
|
||||
if (code === LANG_NONE) {
|
||||
// clear all
|
||||
langs = [];
|
||||
} else {
|
||||
langs[0] = code;
|
||||
if (langs[0] === langs[1]) {
|
||||
langs.length = 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (code === LANG_NONE || code === langs[0]) {
|
||||
langs.splice(1, 1);
|
||||
} else {
|
||||
langs[index] = code;
|
||||
}
|
||||
}
|
||||
setParams({ ...params, languages: langs });
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (uri) {
|
||||
publishCollectionUpdate(params).then((pendingClaim) => {
|
||||
if (pendingClaim) {
|
||||
const claimId = pendingClaim.claim_id;
|
||||
analytics.apiLogPublish(pendingClaim);
|
||||
onDone(claimId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
publishCollection(params, collectionId).then((pendingClaim) => {
|
||||
if (pendingClaim) {
|
||||
const claimId = pendingClaim.claim_id;
|
||||
analytics.apiLogPublish(pendingClaim);
|
||||
onDone(claimId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const collectionClaimIds = JSON.parse(collectionClaimIdsString);
|
||||
setParams({ ...params, claims: collectionClaimIds });
|
||||
clearCollectionErrors();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collectionClaimIdsString, setParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let nameError;
|
||||
if (!name && name !== undefined) {
|
||||
nameError = __('A name is required for your url');
|
||||
} else if (!isNameValid(name)) {
|
||||
nameError = INVALID_NAME_ERROR;
|
||||
}
|
||||
|
||||
setNameError(nameError);
|
||||
}, [name]);
|
||||
|
||||
// every time activechannel or incognito changes, set it.
|
||||
React.useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
setParam({ channel_id: activeChannelId });
|
||||
} else if (incognito) {
|
||||
setParam({ channel_id: undefined });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeChannelId, incognito]);
|
||||
|
||||
// setup initial params after we're sure if it's published or not
|
||||
React.useEffect(() => {
|
||||
if (!uri || (uri && hasClaim)) {
|
||||
updateParams(getCollectionParams());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uri, hasClaim]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classnames('main--contained publishList-wrapper', { 'card--disabled': disabled })}>
|
||||
<Tabs>
|
||||
<TabList className="tabs__list--collection-edit-page">
|
||||
<Tab>{__('General')}</Tab>
|
||||
<Tab>{__('Items')}</Tab>
|
||||
<Tab>{__('Credits')}</Tab>
|
||||
<Tab>{__('Tags')}</Tab>
|
||||
<Tab>{__('Other')}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className={'card-stack'}>
|
||||
<ChannelSelector disabled={disabled} autoSet channelToSet={collectionChannel} />
|
||||
<Card
|
||||
body={
|
||||
<>
|
||||
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
|
||||
<fieldset-section>
|
||||
<label htmlFor="channel_name">{__('Name')}</label>
|
||||
<div className="form-field__prefix">{prefix}</div>
|
||||
</fieldset-section>
|
||||
|
||||
<FormField
|
||||
autoFocus={isNewCollection}
|
||||
type="text"
|
||||
name="channel_name"
|
||||
placeholder={__('MyAwesomeList')}
|
||||
value={params.name}
|
||||
error={nameError}
|
||||
disabled={!isNewCollection}
|
||||
onChange={(e) => setParams({ ...params, name: e.target.value || '' })}
|
||||
/>
|
||||
</fieldset-group>
|
||||
{!isNewCollection && (
|
||||
<span className="form-field__help">{__('This field cannot be changed.')}</span>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
type="text"
|
||||
name="channel_title2"
|
||||
label={__('Title')}
|
||||
placeholder={__('My Awesome List')}
|
||||
value={params.title}
|
||||
onChange={(e) => setParams({ ...params, title: e.target.value })}
|
||||
/>
|
||||
<fieldset-section>
|
||||
<SelectThumbnail
|
||||
thumbnailParam={params.thumbnail_url}
|
||||
thumbnailParamError={thumbError}
|
||||
thumbnailParamStatus={thumbStatus}
|
||||
updateThumbnailParams={handleUpdateThumbnail}
|
||||
/>
|
||||
</fieldset-section>
|
||||
<FormField
|
||||
type="markdown"
|
||||
name="content_description2"
|
||||
label={__('Description')}
|
||||
placeholder={__('Description of your content')}
|
||||
value={params.description}
|
||||
onChange={(text) => setParams({ ...params, description: text })}
|
||||
textAreaMaxLength={FF_MAX_CHARS_IN_DESCRIPTION}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<React.Suspense fallback={null}>
|
||||
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Lazy.Droppable droppableId="list__ordering">
|
||||
{(DroppableProvided) => (
|
||||
<ClaimList
|
||||
uris={collectionUrls}
|
||||
collectionId={collectionId}
|
||||
empty={__('This list has no items.')}
|
||||
showEdit
|
||||
droppableProvided={DroppableProvided}
|
||||
/>
|
||||
)}
|
||||
</Lazy.Droppable>
|
||||
</Lazy.DragDropContext>
|
||||
</React.Suspense>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card
|
||||
body={
|
||||
<FormField
|
||||
className="form-field--price-amount"
|
||||
type="number"
|
||||
name="content_bid2"
|
||||
step="any"
|
||||
label={<LbcSymbol postfix={__('Deposit')} size={14} />}
|
||||
value={params.bid}
|
||||
error={bidError}
|
||||
min="0.0"
|
||||
disabled={false}
|
||||
onChange={(event) =>
|
||||
handleBidChange(parseFloat(event.target.value), amount, balance, setBidError, setParam)
|
||||
}
|
||||
placeholder={0.1}
|
||||
helper={__('Increasing your deposit can help your channel be discovered more easily.')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card
|
||||
body={
|
||||
<TagsSearch
|
||||
suggestMature
|
||||
disableAutoFocus
|
||||
limitSelect={MAX_TAG_SELECT}
|
||||
tagsPassedIn={params.tags || []}
|
||||
label={__('Selected Tags')}
|
||||
onRemove={(clickedTag) => {
|
||||
const newTags = params.tags.slice().filter((tag) => tag.name !== clickedTag.name);
|
||||
setParams({ ...params, tags: newTags });
|
||||
}}
|
||||
onSelect={(newTags) => {
|
||||
newTags.forEach((newTag) => {
|
||||
if (!params.tags.map((savedTag) => savedTag.name).includes(newTag.name)) {
|
||||
setParams({ ...params, tags: [...params.tags, newTag] });
|
||||
} else {
|
||||
// If it already exists and the user types it in, remove it
|
||||
setParams({ ...params, tags: params.tags.filter((tag) => tag.name !== newTag.name) });
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card
|
||||
body={
|
||||
<>
|
||||
<FormField
|
||||
name="language_select"
|
||||
type="select"
|
||||
label={__('Primary Language')}
|
||||
onChange={(event) => handleLanguageChange(0, event.target.value)}
|
||||
value={primaryLanguage}
|
||||
helper={__('Your main content language')}
|
||||
>
|
||||
<option key={'pri-langNone'} value={LANG_NONE}>
|
||||
{__('None selected')}
|
||||
</option>
|
||||
{Object.keys(SUPPORTED_LANGUAGES).map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{SUPPORTED_LANGUAGES[language]}
|
||||
</option>
|
||||
))}
|
||||
</FormField>
|
||||
<FormField
|
||||
name="language_select2"
|
||||
type="select"
|
||||
label={__('Secondary Language')}
|
||||
onChange={(event) => handleLanguageChange(1, event.target.value)}
|
||||
value={secondaryLanguage}
|
||||
disabled={!languageParam[0]}
|
||||
helper={__('Your other content language')}
|
||||
>
|
||||
<option key={'sec-langNone'} value={LANG_NONE}>
|
||||
{__('None selected')}
|
||||
</option>
|
||||
{Object.keys(SUPPORTED_LANGUAGES)
|
||||
.filter((lang) => lang !== languageParam[0])
|
||||
.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{SUPPORTED_LANGUAGES[language]}
|
||||
</option>
|
||||
))}
|
||||
</FormField>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<Card
|
||||
className="card--after-tabs"
|
||||
actions={
|
||||
<>
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="primary"
|
||||
disabled={
|
||||
creatingCollection || updatingCollection || nameError || bidError || thumbnailError || !hasClaims
|
||||
}
|
||||
label={creatingCollection || updatingCollection ? __('Submitting') : __('Submit')}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<Button button="link" label={__('Cancel')} onClick={() => onDone(collectionId)} />
|
||||
</div>
|
||||
{submitError ? (
|
||||
<ErrorText>{submitError}</ErrorText>
|
||||
) : (
|
||||
<p className="help">
|
||||
{__('After submitting, it will take a few minutes for your changes to be live for everyone.')}
|
||||
</p>
|
||||
)}
|
||||
{!isNewCollection && (
|
||||
<div className="section__actions">
|
||||
<ClaimAbandonButton uri={uri} abandonActionCallback={() => replace(`/$/${PAGES.LIBRARY}`)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionForm;
|
|
@ -1,14 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import { makeSelectIndexForUrlInCollection, makeSelectUrlsForCollectionId } from 'redux/selectors/collections';
|
||||
import { selectIndexForUrlInCollection, selectUrlsForCollectionId } from 'redux/selectors/collections';
|
||||
import CollectionButtons from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri, collectionId } = props;
|
||||
|
||||
return {
|
||||
collectionIndex: makeSelectIndexForUrlInCollection(uri, collectionId, true)(state),
|
||||
collectionUris: makeSelectUrlsForCollectionId(collectionId)(state),
|
||||
collectionIndex: selectIndexForUrlInCollection(state, uri, collectionId, true),
|
||||
collectionUris: selectUrlsForCollectionId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as ICONS from 'constants/icons';
|
|||
import Button from 'component/button';
|
||||
import Icon from 'component/common/icon';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
collectionIndex?: number,
|
||||
|
@ -10,26 +11,24 @@ type Props = {
|
|||
dragHandleProps?: any,
|
||||
uri: string,
|
||||
editCollection: (CollectionEditParams) => void,
|
||||
doDisablePlayerDrag?: (disable: boolean) => void,
|
||||
};
|
||||
|
||||
export default function CollectionButtons(props: Props) {
|
||||
const { collectionIndex: foundIndex, collectionUris, dragHandleProps, uri, editCollection } = props;
|
||||
const {
|
||||
collectionIndex: foundIndex,
|
||||
collectionUris,
|
||||
dragHandleProps,
|
||||
uri,
|
||||
editCollection,
|
||||
doDisablePlayerDrag,
|
||||
} = props;
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
|
||||
const lastCollectionIndex = collectionUris ? collectionUris.length - 1 : 0;
|
||||
const collectionIndex = Number(foundIndex);
|
||||
|
||||
const orderButton = (className: string, title: string, icon: string, disabled: boolean, handleClick?: () => void) => (
|
||||
<Button
|
||||
className={`button-collection-manage ${className}`}
|
||||
icon={icon}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => handleClick && handleClick()}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="collection-preview__edit-buttons"
|
||||
|
@ -39,29 +38,45 @@ export default function CollectionButtons(props: Props) {
|
|||
}}
|
||||
>
|
||||
<div className="collection-preview__edit-group" {...dragHandleProps}>
|
||||
<div className="button-collection-manage button-collection-drag top-left bottom-left">
|
||||
<div
|
||||
className="button-collection-manage button-collection-drag top-left bottom-left"
|
||||
onMouseEnter={doDisablePlayerDrag ? () => doDisablePlayerDrag(true) : undefined}
|
||||
onMouseLeave={doDisablePlayerDrag ? () => doDisablePlayerDrag(false) : undefined}
|
||||
>
|
||||
<Icon icon={ICONS.DRAG} title={__('Drag')} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="collection-preview__edit-group">
|
||||
{orderButton('', __('Move Top'), ICONS.UP_TOP, collectionIndex === 0, () =>
|
||||
editCollection({ order: { from: collectionIndex, to: 0 } })
|
||||
)}
|
||||
<OrderButton
|
||||
title={__('Move Top')}
|
||||
icon={ICONS.UP_TOP}
|
||||
disabled={collectionIndex === 0}
|
||||
onClick={() => editCollection({ order: { from: collectionIndex, to: 0 } })}
|
||||
/>
|
||||
|
||||
{orderButton('', __('Move Bottom'), ICONS.DOWN_BOTTOM, collectionIndex === lastCollectionIndex, () =>
|
||||
editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } })
|
||||
)}
|
||||
<OrderButton
|
||||
title={__('Move Bottom')}
|
||||
icon={ICONS.DOWN_BOTTOM}
|
||||
disabled={collectionIndex === lastCollectionIndex}
|
||||
onClick={() => editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="collection-preview__edit-group">
|
||||
{orderButton('', __('Move Up'), ICONS.UP, collectionIndex === 0, () =>
|
||||
editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } })
|
||||
)}
|
||||
<OrderButton
|
||||
title={__('Move Up')}
|
||||
icon={ICONS.UP}
|
||||
disabled={collectionIndex === 0}
|
||||
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } })}
|
||||
/>
|
||||
|
||||
{orderButton('', __('Move Down'), ICONS.DOWN, collectionIndex === lastCollectionIndex, () =>
|
||||
editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } })
|
||||
)}
|
||||
<OrderButton
|
||||
title={__('Move Down')}
|
||||
icon={ICONS.DOWN}
|
||||
disabled={collectionIndex === lastCollectionIndex}
|
||||
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!confirmDelete ? (
|
||||
|
@ -82,11 +97,24 @@ export default function CollectionButtons(props: Props) {
|
|||
onClick={() => setConfirmDelete(false)}
|
||||
/>
|
||||
|
||||
{orderButton('button-collection-delete-confirm bottom-right', __('Remove'), ICONS.DELETE, false, () =>
|
||||
editCollection({ uris: [uri], remove: true })
|
||||
)}
|
||||
<OrderButton
|
||||
className="button-collection-delete-confirm bottom-right"
|
||||
title={__('Remove')}
|
||||
icon={ICONS.DELETE}
|
||||
onClick={() => editCollection({ uris: [uri], remove: true })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonProps = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
const OrderButton = (props: ButtonProps) => {
|
||||
const { className, ...buttonProps } = props;
|
||||
|
||||
return <Button className={classnames('button-collection-manage', className)} {...buttonProps} />;
|
||||
};
|
||||
|
|
17
ui/component/collectionGeneralTab/index.js
Normal file
17
ui/component/collectionGeneralTab/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectChannelForUri } from 'redux/selectors/claims';
|
||||
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
|
||||
|
||||
import CollectionGeneralTab from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri, isPrivateEdit } = props;
|
||||
|
||||
return {
|
||||
collectionChannel: !isPrivateEdit && selectChannelForUri(state, uri),
|
||||
activeChannelClaim: !isPrivateEdit && selectActiveChannelClaim(state),
|
||||
incognito: selectIncognito(state),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(select)(CollectionGeneralTab);
|
170
ui/component/collectionGeneralTab/view.jsx
Normal file
170
ui/component/collectionGeneralTab/view.jsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
// @flow
|
||||
import { DOMAIN } from 'config';
|
||||
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
|
||||
import React from 'react';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import Card from 'component/common/card';
|
||||
import SelectThumbnail from 'component/selectThumbnail';
|
||||
import { FormField } from 'component/common/form';
|
||||
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
|
||||
import Spinner from 'component/spinner';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
params: any,
|
||||
nameError: any,
|
||||
isPrivateEdit?: boolean,
|
||||
incognito: boolean,
|
||||
setThumbnailError: (error: ?string) => void,
|
||||
updateParams: (obj: any) => void,
|
||||
setLoading: (loading: boolean) => void,
|
||||
// -- redux --
|
||||
collectionChannel: ?ChannelClaim,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
};
|
||||
|
||||
function CollectionGeneralTab(props: Props) {
|
||||
const {
|
||||
uri,
|
||||
params,
|
||||
nameError,
|
||||
isPrivateEdit,
|
||||
incognito,
|
||||
setThumbnailError,
|
||||
updateParams,
|
||||
setLoading,
|
||||
// -- redux --
|
||||
collectionChannel,
|
||||
activeChannelClaim,
|
||||
} = props;
|
||||
|
||||
const { name, description } = params;
|
||||
const thumbnailUrl = params.thumbnail_url || params.thumbnail?.url;
|
||||
const title = params.title || name;
|
||||
|
||||
const [thumbStatus, setThumbStatus] = React.useState();
|
||||
const [thumbError, setThumbError] = React.useState();
|
||||
|
||||
const { name: activeChannelName } = activeChannelClaim || {};
|
||||
const isNewCollection = !uri;
|
||||
|
||||
function handleUpdateThumbnail(update: { [string]: string }) {
|
||||
const { thumbnail_url: url, thumbnail_status: status, thumbnail_error: error } = update;
|
||||
|
||||
if (url?.length >= 0) {
|
||||
const newParams = url.length === 0 ? { thumbnail_url: undefined } : update;
|
||||
updateParams(isPrivateEdit ? { thumbnail: { url: newParams.thumbnail_url } } : newParams);
|
||||
setThumbStatus(undefined);
|
||||
setThumbError(undefined);
|
||||
} else {
|
||||
if (status) {
|
||||
setThumbStatus(status);
|
||||
} else {
|
||||
setThumbError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const thumbnailError =
|
||||
thumbError && thumbStatus !== THUMBNAIL_STATUSES.COMPLETE
|
||||
? __('Invalid thumbnail')
|
||||
: thumbStatus === THUMBNAIL_STATUSES.IN_PROGRESS
|
||||
? __('Please wait for thumbnail to finish uploading')
|
||||
: undefined;
|
||||
|
||||
setThumbnailError(thumbnailError);
|
||||
}, [setThumbnailError, thumbError, thumbStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (setLoading) setLoading(!activeChannelClaim);
|
||||
}, [activeChannelClaim, setLoading]);
|
||||
|
||||
if (!activeChannelClaim && !isPrivateEdit) {
|
||||
return (
|
||||
<div className="main--empty">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-stack">
|
||||
{!isPrivateEdit && (
|
||||
<ChannelSelector
|
||||
autoSet
|
||||
channelToSet={collectionChannel}
|
||||
onChannelSelect={(id) => updateParams({ channel_id: id })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
body={
|
||||
<>
|
||||
{!isPrivateEdit && (
|
||||
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
|
||||
<fieldset-section>
|
||||
<label htmlFor="collection_name">{__('Name')}</label>
|
||||
<div className="form-field__prefix">
|
||||
{incognito ? `${DOMAIN}/` : `${DOMAIN}/${activeChannelName}/`}
|
||||
</div>
|
||||
</fieldset-section>
|
||||
|
||||
<FormField
|
||||
autoFocus={isNewCollection}
|
||||
type="text"
|
||||
name="collection_name"
|
||||
placeholder={__('MyAwesomeList')}
|
||||
value={name || ''}
|
||||
error={nameError}
|
||||
disabled={!isNewCollection}
|
||||
onChange={(e) => updateParams({ name: e.target.value || '' })}
|
||||
/>
|
||||
</fieldset-group>
|
||||
)}
|
||||
|
||||
{!isPrivateEdit && (
|
||||
<span className="form-field__help">
|
||||
{isNewCollection
|
||||
? __("This won't be able to be changed in the future.")
|
||||
: __('This field cannot be changed.')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
type="text"
|
||||
name="collection_title"
|
||||
label={__('Title')}
|
||||
placeholder={__('My Awesome Playlist')}
|
||||
value={title || ''}
|
||||
onChange={(e) =>
|
||||
updateParams(isPrivateEdit ? { name: e.target.value || '' } : { title: e.target.value || '' })
|
||||
}
|
||||
/>
|
||||
|
||||
<fieldset-section>
|
||||
<SelectThumbnail
|
||||
thumbnailParam={thumbnailUrl}
|
||||
thumbnailParamError={thumbError}
|
||||
thumbnailParamStatus={thumbStatus}
|
||||
updateThumbnailParams={handleUpdateThumbnail}
|
||||
optional
|
||||
/>
|
||||
</fieldset-section>
|
||||
|
||||
<FormField
|
||||
type="markdown"
|
||||
name="collection_description"
|
||||
label={__('Description')}
|
||||
value={description || ''}
|
||||
onChange={(text) => updateParams({ description: text || '' })}
|
||||
textAreaMaxLength={FF_MAX_CHARS_IN_DESCRIPTION}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionGeneralTab;
|
28
ui/component/collectionItemsList/index.js
Normal file
28
ui/component/collectionItemsList/index.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doCollectionEdit, doFetchItemsInCollection } from 'redux/actions/collections';
|
||||
import {
|
||||
selectUrlsForCollectionId,
|
||||
selectIsResolvingCollectionForId,
|
||||
selectIsCollectionPrivateForId,
|
||||
selectCollectionForId,
|
||||
} from 'redux/selectors/collections';
|
||||
|
||||
import CollectionItemsList from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { collectionId } = props;
|
||||
|
||||
return {
|
||||
collectionUrls: selectUrlsForCollectionId(state, collectionId),
|
||||
collection: selectCollectionForId(state, collectionId),
|
||||
isResolvingCollection: selectIsResolvingCollectionForId(state, collectionId),
|
||||
isPrivateCollection: selectIsCollectionPrivateForId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doCollectionEdit,
|
||||
doFetchItemsInCollection,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(CollectionItemsList);
|
86
ui/component/collectionItemsList/view.jsx
Normal file
86
ui/component/collectionItemsList/view.jsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Spinner from 'component/spinner';
|
||||
|
||||
// prettier-ignore
|
||||
const Lazy = {
|
||||
// $FlowFixMe
|
||||
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
|
||||
// $FlowFixMe
|
||||
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
collectionId: string,
|
||||
// -- redux --
|
||||
collection: Collection,
|
||||
isPrivateCollection: boolean,
|
||||
isResolvingCollection: boolean,
|
||||
collectionUrls: Array<string>,
|
||||
doCollectionEdit: (id: string, params: CollectionEditParams) => void,
|
||||
doFetchItemsInCollection: ({}, ?() => void) => void,
|
||||
};
|
||||
|
||||
const CollectionItemsList = (props: Props) => {
|
||||
const {
|
||||
collectionId,
|
||||
collection,
|
||||
isPrivateCollection,
|
||||
collectionUrls,
|
||||
isResolvingCollection,
|
||||
doCollectionEdit,
|
||||
doFetchItemsInCollection,
|
||||
...claimListProps
|
||||
} = props;
|
||||
|
||||
const [didTryResolve, setDidTryResolve] = React.useState();
|
||||
|
||||
const { totalItems } = collection || {};
|
||||
|
||||
const urlsReady = collectionUrls && (totalItems === undefined || totalItems === collectionUrls.length);
|
||||
const shouldFetchItems = isPrivateCollection || (!urlsReady && collectionId && !didTryResolve && !collection);
|
||||
|
||||
function handleOnDragEnd(result: any) {
|
||||
const { source, destination } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
const { index: from } = source;
|
||||
const { index: to } = destination;
|
||||
|
||||
doCollectionEdit(collectionId, { order: { from, to } });
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (shouldFetchItems) {
|
||||
doFetchItemsInCollection({ collectionId }, () => setDidTryResolve(true));
|
||||
}
|
||||
}, [collectionId, doFetchItemsInCollection, shouldFetchItems]);
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
{isResolvingCollection && (
|
||||
<div className="main--empty">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolvingCollection && (
|
||||
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Lazy.Droppable droppableId="list__ordering">
|
||||
{(DroppableProvided) => (
|
||||
<ClaimList
|
||||
collectionId={collectionId}
|
||||
uris={collectionUrls}
|
||||
droppableProvided={DroppableProvided}
|
||||
{...claimListProps}
|
||||
/>
|
||||
)}
|
||||
</Lazy.Droppable>
|
||||
</Lazy.DragDropContext>
|
||||
)}
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionItemsList;
|
|
@ -1,28 +1,32 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectNameForCollectionId } from 'redux/selectors/collections';
|
||||
import {
|
||||
selectNameForCollectionId,
|
||||
selectIsCollectionBuiltInForId,
|
||||
selectPublishedCollectionNotEditedForId,
|
||||
selectCollectionIsEmptyForId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { selectListShuffle } from 'redux/selectors/content';
|
||||
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
|
||||
import { selectListShuffleForId } from 'redux/selectors/content';
|
||||
import { doToggleShuffleList } from 'redux/actions/content';
|
||||
import CollectionMenuList from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const collectionId = props.collectionId;
|
||||
const shuffleList = selectListShuffle(state);
|
||||
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
|
||||
const playNextUri = shuffle && shuffle[0];
|
||||
const shuffleList = selectListShuffleForId(state, collectionId);
|
||||
const playNextUri = shuffleList && shuffleList.newUrls[0];
|
||||
|
||||
return {
|
||||
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
|
||||
collectionName: selectNameForCollectionId(state, collectionId),
|
||||
playNextUri,
|
||||
isBuiltin: selectIsCollectionBuiltInForId(state, collectionId),
|
||||
publishedNotEdited: selectPublishedCollectionNotEditedForId(state, collectionId),
|
||||
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
doToggleShuffleList: (collectionId) => {
|
||||
dispatch(doToggleLoopList(collectionId, false, true));
|
||||
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
|
||||
},
|
||||
doToggleShuffleList: (params) => dispatch(doToggleShuffleList(params)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CollectionMenuList);
|
||||
|
|
|
@ -8,6 +8,7 @@ import Icon from 'component/common/icon';
|
|||
import * as PAGES from 'constants/pages';
|
||||
import { useHistory } from 'react-router';
|
||||
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
|
||||
import { PUBLISH_PAGE, EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
|
||||
|
||||
type Props = {
|
||||
inline?: boolean,
|
||||
|
@ -15,11 +16,24 @@ type Props = {
|
|||
collectionName?: string,
|
||||
collectionId: string,
|
||||
playNextUri: string,
|
||||
doToggleShuffleList: (string) => void,
|
||||
doToggleShuffleList: (params: { currentUri?: string, collectionId: string, hideToast?: boolean }) => void,
|
||||
isBuiltin: boolean,
|
||||
publishedNotEdited: boolean,
|
||||
collectionEmpty: boolean,
|
||||
};
|
||||
|
||||
function CollectionMenuList(props: Props) {
|
||||
const { inline = false, collectionId, collectionName, doOpenModal, playNextUri, doToggleShuffleList } = props;
|
||||
const {
|
||||
inline = false,
|
||||
collectionId,
|
||||
collectionName,
|
||||
doOpenModal,
|
||||
playNextUri,
|
||||
doToggleShuffleList,
|
||||
isBuiltin,
|
||||
publishedNotEdited,
|
||||
collectionEmpty,
|
||||
} = props;
|
||||
const [doShuffle, setDoShuffle] = React.useState(false);
|
||||
|
||||
const { push } = useHistory();
|
||||
|
@ -47,19 +61,21 @@ function CollectionMenuList(props: Props) {
|
|||
>
|
||||
<Icon size={20} icon={ICONS.MORE_VERTICAL} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuList className="menu__list">
|
||||
{collectionId && collectionName && (
|
||||
<>
|
||||
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
|
||||
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
|
||||
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}`)}>
|
||||
<a className="menu__link" href={`/$/${PAGES.PLAYLIST}/${collectionId}`}>
|
||||
<Icon aria-hidden icon={ICONS.VIEW} />
|
||||
{__('View List')}
|
||||
{__('Open')}
|
||||
</a>
|
||||
</MenuItem>
|
||||
{!collectionEmpty && (
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => {
|
||||
doToggleShuffleList(collectionId);
|
||||
doToggleShuffleList({ collectionId });
|
||||
setDoShuffle(true);
|
||||
}}
|
||||
>
|
||||
|
@ -68,13 +84,28 @@ function CollectionMenuList(props: Props) {
|
|||
{__('Shuffle Play')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{!isBuiltin && (
|
||||
<>
|
||||
{!collectionEmpty && (
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
|
||||
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`)}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.PUBLISH} />
|
||||
{__('Publish List')}
|
||||
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
|
||||
{publishedNotEdited ? __('Update') : __('Publish')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className="comment__menu-option"
|
||||
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
|
||||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.EDIT} />
|
||||
{__('Edit')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
|
@ -83,11 +114,13 @@ function CollectionMenuList(props: Props) {
|
|||
>
|
||||
<div className="menu__link">
|
||||
<Icon aria-hidden icon={ICONS.DELETE} />
|
||||
{__('Delete List')}
|
||||
{__('Delete')}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
|
|
|
@ -1,33 +1,21 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectIsUriResolving, selectClaimIdForUri, makeSelectClaimForClaimId } from 'redux/selectors/claims';
|
||||
import {
|
||||
makeSelectUrlsForCollectionId,
|
||||
makeSelectNameForCollectionId,
|
||||
makeSelectPendingCollectionForId,
|
||||
makeSelectCountForCollectionId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { selectThumbnailForId } from 'redux/selectors/claims';
|
||||
import { selectUrlsForCollectionId } from 'redux/selectors/collections';
|
||||
import { doFetchItemsInCollection } from 'redux/actions/collections';
|
||||
import CollectionPreviewOverlay from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const collectionId = props.collectionId || (props.uri && selectClaimIdForUri(state, props.uri));
|
||||
const claim = props.collectionId && makeSelectClaimForClaimId(props.collectionId)(state);
|
||||
const collectionUri = props.uri || (claim && (claim.canonical_url || claim.permanent_url)) || null;
|
||||
const { collectionId } = props;
|
||||
|
||||
return {
|
||||
collectionId,
|
||||
uri: collectionUri,
|
||||
collectionCount: makeSelectCountForCollectionId(collectionId)(state),
|
||||
collectionName: makeSelectNameForCollectionId(collectionId)(state),
|
||||
collectionItemUrls: makeSelectUrlsForCollectionId(collectionId)(state), // ForId || ForUri
|
||||
pendingCollection: makeSelectPendingCollectionForId(collectionId)(state),
|
||||
claim,
|
||||
isResolvingUri: collectionUri && selectIsUriResolving(state, collectionUri),
|
||||
collectionItemUrls: selectUrlsForCollectionId(state, collectionId),
|
||||
collectionThumbnail: selectThumbnailForId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })), // if collection not resolved, resolve it
|
||||
});
|
||||
const perform = {
|
||||
doFetchItemsInCollection,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(CollectionPreviewOverlay);
|
||||
|
|
|
@ -4,43 +4,42 @@ import { withRouter } from 'react-router-dom';
|
|||
import FileThumbnail from 'component/fileThumbnail';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
collectionId: string,
|
||||
collectionName: string,
|
||||
collectionCount: number,
|
||||
editedCollection?: Collection,
|
||||
pendingCollection?: Collection,
|
||||
claim: ?Claim,
|
||||
// redux
|
||||
collectionItemUrls: Array<string>,
|
||||
fetchCollectionItems: (string) => void,
|
||||
collectionThumbnail: ?string,
|
||||
doFetchItemsInCollection: (options: CollectionFetchParams) => void,
|
||||
};
|
||||
|
||||
function CollectionPreviewOverlay(props: Props) {
|
||||
const { collectionId, collectionItemUrls, fetchCollectionItems } = props;
|
||||
const { collectionId, collectionItemUrls, collectionThumbnail, doFetchItemsInCollection } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!collectionItemUrls) {
|
||||
fetchCollectionItems(collectionId);
|
||||
doFetchItemsInCollection({ collectionId, pageSize: 3 });
|
||||
}
|
||||
}, [collectionId, collectionItemUrls, fetchCollectionItems]);
|
||||
}, [collectionId, collectionItemUrls, doFetchItemsInCollection]);
|
||||
|
||||
if (collectionItemUrls && collectionItemUrls.length > 0) {
|
||||
const displayed = collectionItemUrls.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="collection-preview__overlay-thumbs">
|
||||
<div className="collection-preview__overlay-side" />
|
||||
<div className="collection-preview__overlay-grid">
|
||||
{displayed.map((uri) => (
|
||||
<div className="collection-preview__overlay-grid-items" key={uri}>
|
||||
<FileThumbnail uri={uri} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!collectionItemUrls || collectionItemUrls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if the playlist's thumbnail is the first item of the list, then don't show it again
|
||||
// on the preview overlay (show the second and third instead)
|
||||
const isThumbnailFirstItem = collectionItemUrls.length > 2 && !collectionThumbnail;
|
||||
const displayedItems = isThumbnailFirstItem ? collectionItemUrls.slice(1, 3) : collectionItemUrls.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="claim-preview__collection-wrapper">
|
||||
<ul className="ul--no-style collection-preview-overlay__grid">
|
||||
{displayedItems.map((uri) => (
|
||||
<li className="li--no-style collection-preview-overlay__grid-item" key={uri}>
|
||||
<FileThumbnail uri={uri} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(CollectionPreviewOverlay);
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Icon from 'component/common/icon';
|
||||
type Props = {
|
||||
count: number,
|
||||
};
|
||||
|
||||
export default function collectionCount(props: Props) {
|
||||
const { count = 0 } = props;
|
||||
|
||||
return (
|
||||
<div className={classnames('claim-preview__overlay-properties', 'claim-preview__overlay-properties--small')}>
|
||||
<Icon icon={ICONS.STACK} />
|
||||
<div>{count}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Icon from 'component/common/icon';
|
||||
|
||||
export default function collectionCount() {
|
||||
return (
|
||||
<div className={classnames('claim-preview__overlay-properties', 'claim-preview__overlay-properties--small')}>
|
||||
<Icon icon={ICONS.LOCK} />
|
||||
<div>{__('Private')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
selectIsUriResolving,
|
||||
selectTitleForUri,
|
||||
makeSelectChannelForClaimUri,
|
||||
selectClaimIsNsfwForUri,
|
||||
selectClaimIdForUri,
|
||||
makeSelectClaimForClaimId,
|
||||
} from 'redux/selectors/claims';
|
||||
import {
|
||||
makeSelectUrlsForCollectionId,
|
||||
makeSelectNameForCollectionId,
|
||||
makeSelectEditedCollectionForId,
|
||||
makeSelectPendingCollectionForId,
|
||||
makeSelectCountForCollectionId,
|
||||
makeSelectIsResolvingCollectionForId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { doFetchItemsInCollection, doCollectionDelete } from 'redux/actions/collections';
|
||||
import { doResolveUri } from 'redux/actions/claims';
|
||||
import { selectMutedChannels } from 'redux/selectors/blocked';
|
||||
import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc';
|
||||
import { selectShowMatureContent } from 'redux/selectors/settings';
|
||||
import { getThumbnailFromClaim } from 'util/claim';
|
||||
import CollectionPreviewTile from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const collectionId = props.collectionId || (props.uri && selectClaimIdForUri(state, props.uri));
|
||||
const claim = props.collectionId && makeSelectClaimForClaimId(props.collectionId)(state);
|
||||
const collectionUri = props.uri || (claim && (claim.canonical_url || claim.permanent_url)) || null;
|
||||
|
||||
return {
|
||||
collectionId,
|
||||
uri: collectionUri,
|
||||
collectionCount: makeSelectCountForCollectionId(collectionId)(state),
|
||||
collectionName: makeSelectNameForCollectionId(collectionId)(state),
|
||||
collectionItemUrls: makeSelectUrlsForCollectionId(collectionId)(state), // ForId || ForUri
|
||||
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
|
||||
pendingCollection: makeSelectPendingCollectionForId(collectionId)(state),
|
||||
claim,
|
||||
isResolvingCollectionClaims: makeSelectIsResolvingCollectionForId(collectionId)(state),
|
||||
channelClaim: collectionUri && makeSelectChannelForClaimUri(collectionUri)(state),
|
||||
isResolvingUri: collectionUri && selectIsUriResolving(state, collectionUri),
|
||||
thumbnail: getThumbnailFromClaim(claim),
|
||||
title: collectionUri && selectTitleForUri(state, collectionUri),
|
||||
blackListedOutpoints: selectBlackListedOutpoints(state),
|
||||
filteredOutpoints: selectFilteredOutpoints(state),
|
||||
blockedChannelUris: selectMutedChannels(state),
|
||||
showMature: selectShowMatureContent(state),
|
||||
isMature: selectClaimIsNsfwForUri(state, collectionUri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
resolveUri: (uri) => dispatch(doResolveUri(uri)),
|
||||
resolveCollectionItems: (options) => doFetchItemsInCollection(options),
|
||||
deleteCollection: (id) => dispatch(doCollectionDelete(id)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CollectionPreviewTile);
|
|
@ -1,172 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { NavLink, useHistory } from 'react-router-dom';
|
||||
import ClaimPreviewTile from 'component/claimPreviewTile';
|
||||
import TruncatedText from 'component/common/truncated-text';
|
||||
import CollectionCount from './collectionCount';
|
||||
import CollectionPrivate from './collectionPrivate';
|
||||
import CollectionMenuList from 'component/collectionMenuList';
|
||||
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
|
||||
import FileThumbnail from 'component/fileThumbnail';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
collectionId: string,
|
||||
collectionName: string,
|
||||
collectionCount: number,
|
||||
editedCollection?: Collection,
|
||||
pendingCollection?: Collection,
|
||||
claim: ?Claim,
|
||||
channelClaim: ?ChannelClaim,
|
||||
collectionItemUrls: Array<string>,
|
||||
resolveUri: (string) => void,
|
||||
isResolvingUri: boolean,
|
||||
thumbnail?: string,
|
||||
title?: string,
|
||||
placeholder: boolean,
|
||||
swipeLayout?: boolean,
|
||||
blackListedOutpoints: Array<{ txid: string, nout: number }>,
|
||||
filteredOutpoints: Array<{ txid: string, nout: number }>,
|
||||
blockedChannelUris: Array<string>,
|
||||
isMature?: boolean,
|
||||
showMature: boolean,
|
||||
deleteCollection: (string) => void,
|
||||
resolveCollectionItems: (any) => void,
|
||||
isResolvingCollectionClaims: boolean,
|
||||
};
|
||||
|
||||
function CollectionPreviewTile(props: Props) {
|
||||
const {
|
||||
uri,
|
||||
collectionId,
|
||||
collectionName,
|
||||
collectionCount,
|
||||
isResolvingUri,
|
||||
isResolvingCollectionClaims,
|
||||
collectionItemUrls,
|
||||
claim,
|
||||
resolveCollectionItems,
|
||||
swipeLayout = false,
|
||||
} = props;
|
||||
|
||||
const { push } = useHistory();
|
||||
const hasClaim = Boolean(claim);
|
||||
React.useEffect(() => {
|
||||
if (collectionId && hasClaim && resolveCollectionItems) {
|
||||
resolveCollectionItems({ collectionId, page_size: 5 });
|
||||
}
|
||||
}, [collectionId, hasClaim, resolveCollectionItems]);
|
||||
|
||||
// const signingChannel = claim && claim.signing_channel;
|
||||
|
||||
const navigateUrl =
|
||||
formatLbryUrlForWeb(collectionItemUrls[0] || '/') + (collectionId ? generateListSearchUrlParams(collectionId) : '');
|
||||
|
||||
function handleClick(e) {
|
||||
if (navigateUrl) {
|
||||
push(navigateUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const navLinkProps = {
|
||||
to: navigateUrl,
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
};
|
||||
|
||||
/* REMOVE IF WORKS
|
||||
let shouldHide = false;
|
||||
|
||||
if (isMature && !showMature) {
|
||||
// Unfortunately needed until this is resolved
|
||||
// https://github.com/lbryio/lbry-sdk/issues/2785
|
||||
shouldHide = true;
|
||||
}
|
||||
|
||||
// This will be replaced once blocking is done at the wallet server level
|
||||
if (claim && !shouldHide && blackListedOutpoints) {
|
||||
shouldHide = blackListedOutpoints.some(
|
||||
(outpoint) =>
|
||||
(signingChannel && outpoint.txid === signingChannel.txid && outpoint.nout === signingChannel.nout) ||
|
||||
(outpoint.txid === claim.txid && outpoint.nout === claim.nout)
|
||||
);
|
||||
}
|
||||
// We're checking to see if the stream outpoint
|
||||
// or signing channel outpoint is in the filter list
|
||||
if (claim && !shouldHide && filteredOutpoints) {
|
||||
shouldHide = filteredOutpoints.some(
|
||||
(outpoint) =>
|
||||
(signingChannel && outpoint.txid === signingChannel.txid && outpoint.nout === signingChannel.nout) ||
|
||||
(outpoint.txid === claim.txid && outpoint.nout === claim.nout)
|
||||
);
|
||||
}
|
||||
|
||||
// block stream claims
|
||||
if (claim && !shouldHide && blockedChannelUris.length && signingChannel) {
|
||||
shouldHide = blockedChannelUris.some((blockedUri) => blockedUri === signingChannel.permanent_url);
|
||||
}
|
||||
// block channel claims if we can't control for them in claim search
|
||||
// e.g. fetchRecommendedSubscriptions
|
||||
|
||||
if (shouldHide) {
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
|
||||
if (isResolvingUri || isResolvingCollectionClaims) {
|
||||
return (
|
||||
<li
|
||||
className={classnames('claim-preview--tile', {
|
||||
'swipe-list__item claim-preview--horizontal-tile': swipeLayout,
|
||||
})}
|
||||
>
|
||||
<div className="placeholder media__thumb" />
|
||||
<div className="placeholder__wrapper">
|
||||
<div className="placeholder claim-tile__title" />
|
||||
<div className="placeholder claim-tile__info" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
return <ClaimPreviewTile swipeLayout={swipeLayout} uri={uri} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
role="link"
|
||||
onClick={handleClick}
|
||||
className={classnames('card claim-preview--tile', {
|
||||
'swipe-list__item claim-preview--horizontal-tile': swipeLayout,
|
||||
})}
|
||||
>
|
||||
<NavLink {...navLinkProps}>
|
||||
<FileThumbnail uri={collectionItemUrls && collectionItemUrls.length && collectionItemUrls[0]} tileLayout>
|
||||
<React.Fragment>
|
||||
<div className="claim-preview__claim-property-overlay">
|
||||
<CollectionCount count={collectionCount} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</FileThumbnail>
|
||||
</NavLink>
|
||||
<NavLink {...navLinkProps}>
|
||||
<h2 className="claim-tile__title">
|
||||
<TruncatedText text={collectionName} lines={1} />
|
||||
<CollectionMenuList collectionId={collectionId} />
|
||||
</h2>
|
||||
</NavLink>
|
||||
<div>
|
||||
<div className="claim-tile__info">
|
||||
<React.Fragment>
|
||||
<div className="claim-tile__about">
|
||||
<CollectionPrivate />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionPreviewTile;
|
|
@ -1,21 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectCollectionForId, makeSelectCollectionForIdHasClaimUrl } from 'redux/selectors/collections';
|
||||
import { makeSelectClaimIsPending } from 'redux/selectors/claims';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import CollectionSelectItem from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { collectionId, uri } = props;
|
||||
|
||||
return {
|
||||
collection: makeSelectCollectionForId(collectionId)(state),
|
||||
hasClaim: makeSelectCollectionForIdHasClaimUrl(collectionId, uri)(state),
|
||||
collectionPending: makeSelectClaimIsPending(collectionId)(state),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
editCollection: (id, params) => dispatch(doCollectionEdit(id, params)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CollectionSelectItem);
|
|
@ -1,62 +0,0 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import React from 'react';
|
||||
import { FormField } from 'component/common/form';
|
||||
import Icon from 'component/common/icon';
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
hasClaim: boolean,
|
||||
category: string,
|
||||
edited: boolean,
|
||||
editCollection: (string, CollectionEditParams) => void,
|
||||
uri: string,
|
||||
collectionPending: Collection,
|
||||
};
|
||||
|
||||
function CollectionSelectItem(props: Props) {
|
||||
const { collection, hasClaim, category, editCollection, uri, collectionPending } = props;
|
||||
const { name, id } = collection;
|
||||
const handleChange = (e) => {
|
||||
editCollection(id, { uris: [uri], remove: hasClaim });
|
||||
};
|
||||
|
||||
let icon;
|
||||
switch (category) {
|
||||
case 'builtin':
|
||||
icon =
|
||||
(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
|
||||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
|
||||
ICONS.STACK;
|
||||
break;
|
||||
case 'published':
|
||||
icon = ICONS.STACK;
|
||||
break;
|
||||
default:
|
||||
// 'unpublished'
|
||||
icon = ICONS.LOCK;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'collection-select__item'}>
|
||||
<FormField
|
||||
checked={hasClaim}
|
||||
disabled={collectionPending}
|
||||
icon={icon}
|
||||
type="checkbox"
|
||||
name={`select-${id}`}
|
||||
onChange={handleChange} // edit the collection
|
||||
label={
|
||||
<span>
|
||||
<Icon icon={icon} className={'icon-collection-select'} />
|
||||
{`${name}`}
|
||||
</span>
|
||||
} // the collection name
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionSelectItem;
|
|
@ -1,19 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
selectBuiltinCollections,
|
||||
selectMyPublishedPlaylistCollections,
|
||||
selectMyUnpublishedCollections, // should probably distinguish types
|
||||
// selectSavedCollections,
|
||||
} from 'redux/selectors/collections';
|
||||
import { selectFetchingMyCollections } from 'redux/selectors/claims';
|
||||
import CollectionsListMine from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
builtinCollections: selectBuiltinCollections(state),
|
||||
publishedCollections: selectMyPublishedPlaylistCollections(state),
|
||||
unpublishedCollections: selectMyUnpublishedCollections(state),
|
||||
// savedCollections: selectSavedCollections(state),
|
||||
fetchingCollections: selectFetchingMyCollections(state),
|
||||
});
|
||||
|
||||
export default connect(select)(CollectionsListMine);
|
|
@ -1,250 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import CollectionPreviewTile from 'component/collectionPreviewTile';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Button from 'component/button';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import Icon from 'component/common/icon';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as KEYCODES from 'constants/keycodes';
|
||||
import Yrbl from 'component/yrbl';
|
||||
import classnames from 'classnames';
|
||||
import { FormField, Form } from 'component/common/form';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
|
||||
type Props = {
|
||||
builtinCollections: CollectionGroup,
|
||||
publishedCollections: CollectionGroup,
|
||||
unpublishedCollections: CollectionGroup,
|
||||
// savedCollections: CollectionGroup,
|
||||
fetchingCollections: boolean,
|
||||
};
|
||||
|
||||
const LIST_TYPE = Object.freeze({ ALL: 'All', PRIVATE: 'Private', PUBLIC: 'Public' });
|
||||
const COLLECTION_FILTERS = [LIST_TYPE.ALL, LIST_TYPE.PRIVATE, LIST_TYPE.PUBLIC];
|
||||
const PLAYLIST_SHOW_COUNT = Object.freeze({ DEFAULT: 12, MOBILE: 6 });
|
||||
|
||||
export default function CollectionsListMine(props: Props) {
|
||||
const {
|
||||
builtinCollections,
|
||||
publishedCollections,
|
||||
unpublishedCollections,
|
||||
// savedCollections, these are resolved on startup from sync'd claimIds or urls
|
||||
fetchingCollections,
|
||||
} = props;
|
||||
|
||||
const builtinCollectionsList = (Object.values(builtinCollections || {}): any);
|
||||
const unpublishedCollectionsList = (Object.keys(unpublishedCollections || {}): any);
|
||||
const publishedList = (Object.keys(publishedCollections || {}): any);
|
||||
const hasCollections = unpublishedCollectionsList.length || publishedList.length;
|
||||
const [filterType, setFilterType] = React.useState(LIST_TYPE.ALL);
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
const isMobileScreen = useIsMobile();
|
||||
const playlistShowCount = isMobileScreen ? PLAYLIST_SHOW_COUNT.MOBILE : PLAYLIST_SHOW_COUNT.DEFAULT;
|
||||
|
||||
const playlistPageUrl = `/$/${PAGES.PLAYLISTS}?type=${filterType}`;
|
||||
let collectionsToShow = [];
|
||||
if (filterType === LIST_TYPE.ALL) {
|
||||
collectionsToShow = unpublishedCollectionsList.concat(publishedList);
|
||||
} else if (filterType === LIST_TYPE.PRIVATE) {
|
||||
collectionsToShow = unpublishedCollectionsList;
|
||||
} else if (filterType === LIST_TYPE.PUBLIC) {
|
||||
collectionsToShow = publishedList;
|
||||
}
|
||||
|
||||
let filteredCollections;
|
||||
if (searchText && collectionsToShow) {
|
||||
filteredCollections = collectionsToShow
|
||||
.filter((id) => {
|
||||
return (
|
||||
(unpublishedCollections[id] &&
|
||||
unpublishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) ||
|
||||
(publishedCollections[id] &&
|
||||
publishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()))
|
||||
);
|
||||
})
|
||||
.slice(0, playlistShowCount);
|
||||
} else {
|
||||
filteredCollections = collectionsToShow.slice(0, playlistShowCount) || [];
|
||||
}
|
||||
|
||||
const totalLength = collectionsToShow ? collectionsToShow.length : 0;
|
||||
const filteredLength = filteredCollections.length;
|
||||
const isTruncated = totalLength > filteredLength;
|
||||
|
||||
const watchLater = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID);
|
||||
const favorites = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.FAVORITES_ID);
|
||||
const builtin = [watchLater, favorites];
|
||||
function escapeListener(e: SyntheticKeyboardEvent<*>) {
|
||||
if (e.keyCode === KEYCODES.ESCAPE) {
|
||||
e.preventDefault();
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
|
||||
function onTextareaFocus() {
|
||||
window.addEventListener('keydown', escapeListener);
|
||||
}
|
||||
|
||||
function onTextareaBlur() {
|
||||
window.removeEventListener('keydown', escapeListener);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Built-in lists */}
|
||||
{builtin.map((list: Collection) => {
|
||||
const { items: itemUrls } = list;
|
||||
return (
|
||||
<div className="claim-grid__wrapper" key={list.name}>
|
||||
<>
|
||||
{Boolean(itemUrls && itemUrls.length) && (
|
||||
<>
|
||||
<h1 className="claim-grid__header">
|
||||
<Button
|
||||
className="claim-grid__title"
|
||||
button="link"
|
||||
navigate={`/$/${PAGES.LIST}/${list.id}`}
|
||||
label={
|
||||
<span className="claim-grid__title-span">
|
||||
{__(`${list.name}`)}
|
||||
<div className="claim-grid__title--empty">
|
||||
<Icon
|
||||
className="icon--margin-right"
|
||||
icon={
|
||||
(list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
|
||||
(list.id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
|
||||
ICONS.STACK
|
||||
}
|
||||
/>
|
||||
{itemUrls.length}
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</h1>
|
||||
<ClaimList
|
||||
swipeLayout={isMobileScreen}
|
||||
tileLayout
|
||||
key={list.name}
|
||||
uris={itemUrls.slice(0, 6)}
|
||||
collectionId={list.id}
|
||||
showUnresolvedClaims
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!(itemUrls && itemUrls.length) && (
|
||||
<h1 className="claim-grid__header claim-grid__title">
|
||||
{__(`${list.name}`)}
|
||||
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
|
||||
</h1>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Playlists: header */}
|
||||
<div className="claim-grid__wrapper">
|
||||
<div className="claim-grid__header section">
|
||||
<h1 className="claim-grid__title">
|
||||
<Button
|
||||
className="claim-grid__title"
|
||||
button="link"
|
||||
navigate={playlistPageUrl}
|
||||
label={
|
||||
<span className="claim-grid__title-span">
|
||||
{__('Playlists')}
|
||||
<div className="claim-grid__title--empty">
|
||||
<Icon className="icon--margin-right" icon={ICONS.STACK} />
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!hasCollections && !fetchingCollections && (
|
||||
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
|
||||
)}
|
||||
{!hasCollections && fetchingCollections && (
|
||||
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Playlists: search */}
|
||||
<div className="section__header-action-stack">
|
||||
<div className="section__header--actions">
|
||||
<div className="claim-search__wrapper">
|
||||
<div className="claim-search__menu-group">
|
||||
{COLLECTION_FILTERS.map((value) => (
|
||||
<Button
|
||||
label={__(value)}
|
||||
key={value}
|
||||
button="alt"
|
||||
onClick={() => setFilterType(value)}
|
||||
className={classnames('button-toggle', {
|
||||
'button-toggle--active': filterType === value,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Form onSubmit={() => {}} className="wunderbar--inline">
|
||||
<Icon icon={ICONS.SEARCH} />
|
||||
<FormField
|
||||
name="collection_search"
|
||||
onFocus={onTextareaFocus}
|
||||
onBlur={onTextareaBlur}
|
||||
className="wunderbar__input--inline"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
type="text"
|
||||
placeholder={__('Search')}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
<p className="collection-grid__results-summary">
|
||||
{isTruncated && (
|
||||
<>
|
||||
{__('Showing %filtered% results of %total%', { filtered: filteredLength, total: totalLength })}
|
||||
{`${searchText ? ' (' + __('filtered') + ') ' : ' '}`}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
button="link"
|
||||
navigate={playlistPageUrl}
|
||||
label={<span className="claim-grid__title-span">{__('View All Playlists')}</span>}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Playlists: tiles */}
|
||||
{Boolean(hasCollections) && (
|
||||
<div>
|
||||
<div
|
||||
className={classnames('claim-grid', {
|
||||
'swipe-list': isMobileScreen,
|
||||
})}
|
||||
>
|
||||
{filteredCollections &&
|
||||
filteredCollections.length > 0 &&
|
||||
filteredCollections.map((key) => (
|
||||
<CollectionPreviewTile swipeLayout={isMobileScreen} tileLayout collectionId={key} key={key} />
|
||||
))}
|
||||
{!filteredCollections.length && <div className="empty main--empty">{__('No matching playlists')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasCollections && !fetchingCollections && (
|
||||
<div className="main--empty">
|
||||
<Yrbl type={'sad'} title={__('You have no lists yet. Better start hoarding!')} />
|
||||
</div>
|
||||
)}
|
||||
{!hasCollections && fetchingCollections && (
|
||||
<div className="main--empty">
|
||||
<h2 className="main--empty empty">{__('Loading...')}</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||
import { doChannelMute } from 'redux/actions/blocked';
|
||||
import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { doSetPlayingUri } from 'redux/actions/content';
|
||||
import { doClearPlayingUri } from 'redux/actions/content';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { selectClaimIsMine, selectClaimForUri } from 'redux/selectors/claims';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
|
@ -23,7 +23,7 @@ const select = (state, props) => {
|
|||
|
||||
const perform = (dispatch) => ({
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })),
|
||||
clearPlayingUri: () => dispatch(doClearPlayingUri()),
|
||||
muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)),
|
||||
pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)),
|
||||
commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) =>
|
||||
|
|
|
@ -79,7 +79,7 @@ class FreezeframeLite {
|
|||
}
|
||||
|
||||
process(freeze) {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
const { $canvas, $image, $container } = freeze;
|
||||
const { clientWidth, clientHeight } = $image;
|
||||
|
||||
|
@ -159,7 +159,7 @@ class FreezeframeLite {
|
|||
}
|
||||
|
||||
emit(event, items, isPlaying) {
|
||||
this.eventListeners[event].forEach(cb => {
|
||||
this.eventListeners[event].forEach((cb) => {
|
||||
cb(items.length === 1 ? items[0] : items, isPlaying);
|
||||
});
|
||||
}
|
|
@ -5,7 +5,7 @@ export const isTouch = () => {
|
|||
return 'ontouchstart' in window || 'onmsgesturechange' in window;
|
||||
};
|
||||
|
||||
export const htmlToNode = html => {
|
||||
export const htmlToNode = (html) => {
|
||||
const $wrap = window.document.createElement('div');
|
||||
$wrap.innerHTML = html;
|
||||
const $content = $wrap.childNodes;
|
38
ui/component/common/bid-help-text.jsx
Normal file
38
ui/component/common/bid-help-text.jsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
// @flow
|
||||
type Props = {
|
||||
uri: ?string,
|
||||
isResolvingUri: boolean,
|
||||
amountNeededForTakeover: number,
|
||||
};
|
||||
|
||||
function BidHelpText(props: Props) {
|
||||
const { uri, isResolvingUri, amountNeededForTakeover } = props;
|
||||
|
||||
let bidHelpText;
|
||||
|
||||
if (uri) {
|
||||
if (isResolvingUri) {
|
||||
bidHelpText = __('Checking the winning claim amount...');
|
||||
} else if (amountNeededForTakeover === 0) {
|
||||
bidHelpText = __('You currently have the highest bid for this name.');
|
||||
} else if (!amountNeededForTakeover) {
|
||||
bidHelpText = __(
|
||||
'Any amount will give you the highest bid, but larger amounts help your content be trusted and discovered.'
|
||||
);
|
||||
} else {
|
||||
bidHelpText = __(
|
||||
'If you bid more than %amount% LBRY Credits, when someone navigates to %uri%, it will load your published content. However, you can get a longer version of this URL for any bid.',
|
||||
{
|
||||
amount: amountNeededForTakeover,
|
||||
uri: uri,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
bidHelpText = __('These LBRY Credits remain yours and the deposit can be undone at any time.');
|
||||
}
|
||||
|
||||
return bidHelpText;
|
||||
}
|
||||
|
||||
export default BidHelpText;
|
|
@ -24,9 +24,14 @@ type Props = {
|
|||
onClick?: () => void,
|
||||
children?: Node,
|
||||
secondPane?: Node,
|
||||
slimHeader?: boolean,
|
||||
colorHeader?: boolean,
|
||||
singlePane?: boolean,
|
||||
headerActions?: Node,
|
||||
gridHeader?: boolean,
|
||||
};
|
||||
|
||||
export default function Card(props: Props) {
|
||||
function Card(props: Props) {
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
|
@ -45,7 +50,13 @@ export default function Card(props: Props) {
|
|||
onClick,
|
||||
children,
|
||||
secondPane,
|
||||
slimHeader,
|
||||
colorHeader,
|
||||
singlePane,
|
||||
headerActions,
|
||||
gridHeader,
|
||||
} = props;
|
||||
|
||||
const [expanded, setExpanded] = useState(defaultExpand);
|
||||
const expandable = defaultExpand !== undefined;
|
||||
|
||||
|
@ -63,28 +74,33 @@ export default function Card(props: Props) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="card__first-pane">
|
||||
<FirstPaneWrapper singlePane={singlePane}>
|
||||
{(title || subtitle) && (
|
||||
<div
|
||||
className={classnames('card__header--between', {
|
||||
'card__header--nowrap': noTitleWrap,
|
||||
'card__header--slim': slimHeader,
|
||||
'card__header--bg-color': colorHeader,
|
||||
'card__header--grid': gridHeader,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classnames('card__title-section', {
|
||||
'card__title-section--body-list': isBodyList,
|
||||
'card__title-section--smallx': smallTitle,
|
||||
})}
|
||||
>
|
||||
<div className={classnames('card__title-section', { 'card__title-section--body-list': isBodyList })}>
|
||||
{icon && <Icon sectionIcon icon={icon} />}
|
||||
<div>
|
||||
{isPageTitle && <h1 className="card__title">{title}</h1>}
|
||||
{!isPageTitle && (
|
||||
<h2 className={classnames('card__title', { 'card__title--small': smallTitle })}>{title}</h2>
|
||||
|
||||
<div className="card__title-text">
|
||||
<TitleWrapper isPageTitle={isPageTitle} smallTitle={smallTitle}>
|
||||
{title}
|
||||
</TitleWrapper>
|
||||
|
||||
{subtitle && (
|
||||
<div className={classnames('card__subtitle', { 'card__subtitle--small': smallTitle })}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <div className="card__subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(titleActions || expandable) && (
|
||||
<div className="card__title-actions-container">
|
||||
{titleActions && (
|
||||
<div
|
||||
|
@ -107,8 +123,12 @@ export default function Card(props: Props) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!expandable || (expandable && expanded)) && (
|
||||
<>
|
||||
{body && (
|
||||
|
@ -125,9 +145,40 @@ export default function Card(props: Props) {
|
|||
{children && <div className="card__main-actions">{children}</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{nag}
|
||||
</div>
|
||||
</FirstPaneWrapper>
|
||||
|
||||
{secondPane && <div className="card__second-pane">{secondPane}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type FirstPaneProps = {
|
||||
singlePane?: boolean,
|
||||
children: any,
|
||||
};
|
||||
|
||||
const FirstPaneWrapper = (props: FirstPaneProps) => {
|
||||
const { singlePane, children } = props;
|
||||
|
||||
return singlePane ? children : <div className="card__first-pane">{children}</div>;
|
||||
};
|
||||
|
||||
type TitleProps = {
|
||||
isPageTitle?: boolean,
|
||||
smallTitle?: boolean,
|
||||
children?: any,
|
||||
};
|
||||
|
||||
const TitleWrapper = (props: TitleProps) => {
|
||||
const { isPageTitle, smallTitle, children } = props;
|
||||
|
||||
return isPageTitle ? (
|
||||
<h1 className="card__title">{children}</h1>
|
||||
) : (
|
||||
<h2 className={classnames('card__title', { 'card__title--small': smallTitle })}>{children}</h2>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
|
@ -3,14 +3,18 @@ import classnames from 'classnames';
|
|||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
isChannel: boolean,
|
||||
type: string,
|
||||
isChannel?: boolean,
|
||||
type?: string,
|
||||
WrapperElement?: string,
|
||||
xsmall?: boolean,
|
||||
};
|
||||
|
||||
function ClaimPreviewLoading(props: Props) {
|
||||
const { isChannel, type } = props;
|
||||
const { isChannel, type, WrapperElement = 'li', xsmall } = props;
|
||||
|
||||
return (
|
||||
<li
|
||||
// Uses the same WrapperElement as claimPreview so it's consistent with the rest of the list components
|
||||
<WrapperElement
|
||||
className={classnames('placeholder claim-preview__wrapper', {
|
||||
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
|
||||
'claim-preview__wrapper--inline': type === 'inline',
|
||||
|
@ -18,7 +22,7 @@ function ClaimPreviewLoading(props: Props) {
|
|||
})}
|
||||
>
|
||||
<div className={classnames('claim-preview', { 'claim-preview--large': type === 'large' })}>
|
||||
<div className="media__thumb" />
|
||||
<div className={classnames('media__thumb', { 'media__thumb--small': xsmall })} />
|
||||
<div className="placeholder__wrapper">
|
||||
<div className="claim-preview__title" />
|
||||
<div className="claim-preview__title_b" />
|
||||
|
@ -31,7 +35,7 @@ function ClaimPreviewLoading(props: Props) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</WrapperElement>
|
||||
);
|
||||
}
|
||||
|
13
ui/component/common/collection-private-icon.jsx
Normal file
13
ui/component/common/collection-private-icon.jsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Icon from 'component/common/icon';
|
||||
|
||||
const CollectionPrivateIcon = () => (
|
||||
<div className="claim-preview__overlay-properties--small visibility-icon">
|
||||
<Icon icon={ICONS.LOCK} />
|
||||
<span>{__('Private')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CollectionPrivateIcon;
|
46
ui/component/common/file-action-button.jsx
Normal file
46
ui/component/common/file-action-button.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import Tooltip from 'component/common/tooltip';
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
iconSize?: number,
|
||||
noStyle?: boolean,
|
||||
navigate?: string,
|
||||
requiresAuth?: boolean,
|
||||
requiresChannel?: boolean,
|
||||
};
|
||||
|
||||
function FileActionButton(props: Props) {
|
||||
const { title, iconSize, noStyle, ...buttonProps } = props;
|
||||
const { navigate, requiresAuth, requiresChannel } = buttonProps;
|
||||
|
||||
if (navigate || requiresAuth || requiresChannel) {
|
||||
return (
|
||||
<Tooltip title={title} arrow={false} enterDelay={100}>
|
||||
<div className="button--file-action--tooltip-wrapper">
|
||||
<Button
|
||||
button={noStyle ? 'alt' : undefined}
|
||||
className={noStyle ? undefined : 'button--file-action button--file-action--tooltip'}
|
||||
iconSize={iconSize || 16}
|
||||
{...buttonProps}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={title} arrow={false} enterDelay={100}>
|
||||
<Button
|
||||
button={noStyle ? 'alt' : undefined}
|
||||
className={noStyle ? undefined : 'button--file-action'}
|
||||
iconSize={iconSize || 16}
|
||||
{...buttonProps}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileActionButton;
|
|
@ -1,14 +1,20 @@
|
|||
// @flow
|
||||
import React, { useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import Freezeframe from './FreezeframeLite';
|
||||
import useLazyLoading from 'effects/use-lazy-loading';
|
||||
|
||||
const FreezeframeWrapper = (props) => {
|
||||
type Props = {
|
||||
src: string,
|
||||
className: string,
|
||||
children: any,
|
||||
};
|
||||
|
||||
const FreezeframeWrapper = (props: Props) => {
|
||||
const { src, className, children } = props;
|
||||
|
||||
const imgRef = React.useRef();
|
||||
const freezeframe = React.useRef();
|
||||
// eslint-disable-next-line
|
||||
const { src, className, children } = props;
|
||||
|
||||
const srcLoaded = useLazyLoading(imgRef);
|
||||
|
||||
|
@ -20,17 +26,10 @@ const FreezeframeWrapper = (props) => {
|
|||
|
||||
return (
|
||||
<div className={classnames(className, 'freezeframe-wrapper')}>
|
||||
<>
|
||||
<img ref={imgRef} data-src={src} className="freezeframe-img" />
|
||||
{children}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FreezeframeWrapper.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
className: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default FreezeframeWrapper;
|
|
@ -2500,6 +2500,7 @@ export const icons = {
|
|||
strokeLinejoin="round"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,0,0)">
|
||||
<path d="M1.500 12.000 A10.500 10.500 0 1 0 22.500 12.000 A10.500 10.500 0 1 0 1.500 12.000 Z" />
|
||||
|
@ -2668,25 +2669,55 @@ export const icons = {
|
|||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.REPEAT]: buildIcon(
|
||||
<g>
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.SHUFFLE]: buildIcon(
|
||||
<g>
|
||||
<polyline points="16 3 21 3 21 8" />
|
||||
<line x1="4" y1="20" x2="21" y2="3" />
|
||||
<polyline points="21 16 21 21 16 21" />
|
||||
<line x1="15" y1="15" x2="21" y2="21" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.REPEAT]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke={color}
|
||||
{...otherProps}
|
||||
>
|
||||
<polyline stroke={color} points="17 1 21 5 17 9" />
|
||||
<path stroke={color} d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline stroke={color} points="7 23 3 19 7 15" />
|
||||
<path stroke={color} d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.SHUFFLE]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke={color}
|
||||
{...otherProps}
|
||||
>
|
||||
<polyline stroke={color} points="16 3 21 3 21 8" />
|
||||
<line stroke={color} x1="4" y1="20" x2="21" y2="3" />
|
||||
<polyline stroke={color} points="21 16 21 21 16 21" />
|
||||
<line stroke={color} x1="15" y1="15" x2="21" y2="21" />
|
||||
<line stroke={color} x1="4" y1="4" x2="9" y2="9" />
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.HOLD_PHONE]: buildIcon(
|
||||
<g>
|
||||
<svg>
|
||||
<path d="M12 17.491L1.5 17.491" />
|
||||
<path d="M8,19.241H8a.25.25,0,0,1,.25.25h0a.25.25,0,0,1-.25.25H8a.25.25,0,0,1-.25-.25h0a.25.25,0,0,1,.25-.25" />
|
||||
<path d="M12.5,21.491h-9a2,2,0,0,1-2-2v-17a2,2,0,0,1,2-2h9a2,2,0,0,1,2,2V13.265" />
|
||||
|
@ -2694,7 +2725,7 @@ export const icons = {
|
|||
<path d="M16.5,14.868l-3.727-2.987a1.331,1.331,0,0,0-1.883,1.883l3.61,4.079V20.4c0,1.206,1.724,3.111,1.724,3.111" />
|
||||
<path d="M5.750 5.741 A2.250 2.250 0 1 0 10.250 5.741 A2.250 2.250 0 1 0 5.750 5.741 Z" />
|
||||
<path d="M12.11,11.524a4.628,4.628,0,0,0-8.61.967" />
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
[ICONS.LIFE]: buildIcon(
|
||||
<g>
|
||||
|
@ -3036,6 +3067,41 @@ export const icons = {
|
|||
</g>
|
||||
</svg>
|
||||
),
|
||||
[ICONS.PLAYLIST_PLAYBACK]: (props: IconProps) => {
|
||||
const { size = 50, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...otherProps}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="5 0 30 30"
|
||||
width={size}
|
||||
height={size === 30 ? 18 : 30}
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path style={{ fill: color }} d="M13.9,20.9v-9.7l6.9,4.7L13.9,20.9z M15.1,13.4v5.1l3.7-2.7L15.1,13.4z" />
|
||||
<path
|
||||
style={{ fill: color }}
|
||||
d="M29,16c0,5.3-3.2,9.8-7.7,11.9l0.3,1.4c5.2-2.2,8.8-7.3,8.8-13.3c0-6-3.7-11.1-8.9-13.3l-0.3,1.4C25.8,6.1,29,10.7,29,16z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: color }}
|
||||
d="M26,16c0-3.9-2.2-7.2-5.4-8.9l-0.3,1.4c2.6,1.5,4.3,4.3,4.3,7.5c0,3.2-1.7,5.9-4.3,7.4l0.3,1.4C23.8,23.2,26,19.9,26,16z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: color }}
|
||||
d="M3,16c0-5.3,3.2-9.9,7.8-11.9l-0.3-1.4C5.3,4.9,1.6,10,1.6,16c0,6,3.6,11.1,8.8,13.3l0.3-1.4C6.2,25.8,3,21.3,3,16z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: color }}
|
||||
d="M6,16c0,3.9,2.2,7.2,5.4,8.9l0.3-1.4c-2.6-1.5-4.3-4.3-4.3-7.4c0-3.2,1.8-6,4.4-7.5l-0.3-1.4C8.2,8.8,6,12.1,6,16z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.PREMIUM]: (props: CustomProps) => (
|
||||
<svg
|
||||
{...props}
|
||||
|
@ -3302,4 +3368,109 @@ export const icons = {
|
|||
<path d="M0.6,11.8c0-6,5-11,11-11 M9.6,7.2v9.5l6.9-4.7L9.6,7.2z M-2.1,9.5l2.9,2.9l3.2-2.7 M11.4,23.2 v-0.9 M5.6,21.5L6,20.6 M2.1,16.4l-0.8,0.4 M17,20.8l0.5,0.8 M20.9,16.7l0.8,0.5 M23.1,11l-0.9,0.1 M21,5.2l-0.7,0.5 M16.2,1.2 L15.8,2" />
|
||||
</svg>
|
||||
),
|
||||
[ICONS.PLAYLIST]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke={color}
|
||||
style={{ overflow: 'visible' }}
|
||||
{...otherProps}
|
||||
>
|
||||
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
|
||||
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: 'none' }} />
|
||||
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
|
||||
<path
|
||||
d="M3.75,7.64V3.86a.36.36,0,0,1,.55-.31L7.57,5.44a.36.36,0,0,1,0,.62L4.3,8A.36.36,0,0,1,3.75,7.64Z"
|
||||
style={{ fill: 'none' }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.PLAYLIST_ADD]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ overflow: 'visible' }}
|
||||
{...otherProps}
|
||||
>
|
||||
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
|
||||
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: 'none' }} />
|
||||
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
|
||||
<line x1="5.75" y1="3" x2="5.75" y2="8.5" />
|
||||
<line x1="3" y1="5.75" x2="8.5" y2="5.75" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.PLAYLIST_FILLED]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ overflow: 'visible' }}
|
||||
{...otherProps}
|
||||
>
|
||||
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
|
||||
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: color }} />
|
||||
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
|
||||
<path
|
||||
d="M3.75,7.64V3.86a.36.36,0,0,1,.55-.31L7.57,5.44a.36.36,0,0,1,0,.62L4.3,8A.36.36,0,0,1,3.75,7.64Z"
|
||||
style={{ stroke: 'var(--color-header-background)', strokeWidth: 1.2 }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
[ICONS.ARRANGE]: (props: IconProps) => {
|
||||
const { size = 24, color = 'currentColor', ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke={color}
|
||||
{...otherProps}
|
||||
>
|
||||
<path
|
||||
strokeWidth="1.5"
|
||||
d="M0.5 1.42857C0.5 0.915736 0.915736 0.5 1.42857 0.5H12.5714C13.0843 0.5 13.5 0.915736 13.5 1.42857V12.5714C13.5 13.0843 13.0843 13.5 12.5714 13.5H1.42857C0.915736 13.5 0.5 13.0843 0.5 12.5714V1.42857Z"
|
||||
/>
|
||||
<path d="M8.85715 5.14279L7.00001 3.28564L5.14287 5.14279" />
|
||||
<path d="M8.85715 8.85742L7.00001 10.7146L5.14287 8.85742" />
|
||||
<path d="M7.00002 3.28564V10.7142" />
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
10
ui/component/common/section-divider.jsx
Normal file
10
ui/component/common/section-divider.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
const SectionDivider = () => (
|
||||
<div className="section__divider">
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SectionDivider;
|
|
@ -6,13 +6,14 @@ type Props = {
|
|||
lines: number,
|
||||
showTooltip?: boolean,
|
||||
children?: React.Node,
|
||||
style?: any,
|
||||
};
|
||||
|
||||
const TruncatedText = (props: Props) => {
|
||||
const { text, children, lines, showTooltip } = props;
|
||||
const { text, children, lines, showTooltip, style } = props;
|
||||
const tooltip = showTooltip ? children || text : '';
|
||||
return (
|
||||
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines }}>
|
||||
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines, ...style }}>
|
||||
{children || text}
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import ClaimDeleteButton from './view';
|
||||
|
||||
const perform = {
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default connect(null, perform)(ClaimDeleteButton);
|
25
ui/component/fileActions/internal/claimDeleteButton/view.jsx
Normal file
25
ui/component/fileActions/internal/claimDeleteButton/view.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
// @flow
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
// redux
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
function ClaimDeleteButton(props: Props) {
|
||||
const { uri, doOpenModal } = props;
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
title={__('Remove from your library')}
|
||||
icon={ICONS.DELETE}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM_FILE_REMOVE, { uri })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimDeleteButton;
|
|
@ -0,0 +1,22 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doPrepareEdit } from 'redux/actions/publish';
|
||||
import { selectClaimForUri, selectChannelNameForClaimUri, selectClaimIsMineForUri } from 'redux/selectors/claims';
|
||||
import ClaimPublishButton from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
const claim = selectClaimForUri(state, uri);
|
||||
|
||||
return {
|
||||
claim,
|
||||
channelName: selectChannelNameForClaimUri(state, uri),
|
||||
claimIsMine: selectClaimIsMineForUri(state, uri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doPrepareEdit,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(ClaimPublishButton);
|
|
@ -0,0 +1,46 @@
|
|||
// @flow
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import { buildURI } from 'util/lbryURI';
|
||||
import React from 'react';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
type Props = {
|
||||
isLivestreamClaim: boolean,
|
||||
// redux
|
||||
claim: ?Claim,
|
||||
channelName: ?string,
|
||||
claimIsMine: boolean,
|
||||
doPrepareEdit: (claim: Claim, uri: string) => void,
|
||||
};
|
||||
|
||||
function ClaimPublishButton(props: Props) {
|
||||
const { isLivestreamClaim, claim, channelName, claimIsMine, doPrepareEdit } = props;
|
||||
|
||||
// We want to use the short form uri for editing
|
||||
// This is what the user is used to seeing, they don't care about the claim id
|
||||
// We will select the claim id before they publish
|
||||
let editUri;
|
||||
if (claim && claimIsMine) {
|
||||
const { name: claimName, claim_id: claimId } = claim;
|
||||
const uriObject: LbryUrlObj = { streamName: claimName, streamClaimId: claimId };
|
||||
|
||||
if (channelName) {
|
||||
uriObject.channelName = channelName;
|
||||
}
|
||||
|
||||
editUri = buildURI(uriObject);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
title={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
|
||||
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
|
||||
icon={ICONS.EDIT}
|
||||
navigate={`/$/${PAGES.UPLOAD}`}
|
||||
onClick={!claim ? undefined : () => doPrepareEdit(claim, editUri)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimPublishButton;
|
|
@ -5,7 +5,6 @@ import * as PAGES from 'constants/pages';
|
|||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import { buildURI } from 'util/lbryURI';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import * as RENDER_MODES from 'constants/file_render_modes';
|
||||
|
@ -16,7 +15,10 @@ import FileReactions from 'component/fileReactions';
|
|||
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
|
||||
import Icon from 'component/common/icon';
|
||||
import { webDownloadClaim } from 'util/downloadClaim';
|
||||
import Tooltip from 'component/common/tooltip';
|
||||
import ClaimShareButton from 'component/claimShareButton';
|
||||
import ClaimRepostButton from 'component/claimRepostButton';
|
||||
import ClaimPublishButton from './internal/claimPublishButton';
|
||||
import ClaimDeleteButton from './internal/claimDeleteButton';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
|
@ -121,53 +123,16 @@ export default function FileActions(props: Props) {
|
|||
|
||||
{!isAPreorder && <ClaimSupportButton uri={uri} fileAction />}
|
||||
|
||||
<ClaimCollectionAddButton uri={uri} fileAction />
|
||||
<ClaimCollectionAddButton uri={uri} />
|
||||
|
||||
{!hideRepost && !isMobile && !isLivestreamClaim && (
|
||||
<Tooltip title={__('Repost')} arrow={false}>
|
||||
<Button
|
||||
button="alt"
|
||||
className="button--file-action"
|
||||
icon={ICONS.REPOST}
|
||||
label={
|
||||
claimMeta.reposted > 1 ? __(`%repost_total% Reposts`, { repost_total: claimMeta.reposted }) : __('Repost')
|
||||
}
|
||||
requiresAuth
|
||||
onClick={handleRepostClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!hideRepost && !isMobile && !isLivestreamClaim && <ClaimRepostButton uri={uri} />}
|
||||
|
||||
<Tooltip title={__('Share')} arrow={false}>
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.SHARE}
|
||||
label={__('Share')}
|
||||
onClick={() => doOpenModal(MODALS.SOCIAL_SHARE, { uri, webShareable, collectionId })}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ClaimShareButton uri={uri} fileAction webShareable={webShareable} collectionId={collectionId} />
|
||||
|
||||
{claimIsMine && !isMobile && (
|
||||
<>
|
||||
<Tooltip title={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')} arrow={false}>
|
||||
<div style={{ margin: '0px' }}>
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.EDIT}
|
||||
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
|
||||
onClick={() => doPrepareEdit(claim, editUri, claimType)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={__('Remove from your library')} arrow={false}>
|
||||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.DELETE}
|
||||
description={__('Delete')}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM_FILE_REMOVE, { uri })}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ClaimPublishButton uri={uri} isLivestreamClaim={isLivestreamClaim} />
|
||||
<ClaimDeleteButton uri={uri} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from 'redux/selectors/file_info';
|
||||
import { selectCostInfoForUri } from 'lbryinc';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { doSetPlayingUri, doDownloadUri } from 'redux/actions/content';
|
||||
import { doClearPlayingUri, doDownloadUri } from 'redux/actions/content';
|
||||
import FileDownloadLink from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
|
@ -27,7 +27,7 @@ const select = (state, props) => {
|
|||
|
||||
const perform = (dispatch) => ({
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
pause: () => dispatch(doSetPlayingUri({ uri: null })),
|
||||
pause: () => dispatch(doClearPlayingUri()),
|
||||
download: (uri) => dispatch(doDownloadUri(uri)),
|
||||
});
|
||||
|
||||
|
|
|
@ -3,11 +3,10 @@ import * as REACTION_TYPES from 'constants/reactions';
|
|||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Button from 'component/button';
|
||||
import RatioBar from 'component/ratioBar';
|
||||
import { formatNumberWithCommas } from 'util/number';
|
||||
import NudgeFloating from 'component/nudgeFloating';
|
||||
// import Tooltip from 'component/common/tooltip';
|
||||
import FileActionButton from 'component/common/file-action-button';
|
||||
|
||||
const LIVE_REACTION_FETCH_MS = 1000 * 45;
|
||||
|
||||
|
@ -41,39 +40,6 @@ export default function FileReactions(props: Props) {
|
|||
doReactionDislike,
|
||||
} = props;
|
||||
|
||||
const likeIcon = myReaction === REACTION_TYPES.LIKE ? ICONS.FIRE_ACTIVE : ICONS.FIRE;
|
||||
const dislikeIcon = myReaction === REACTION_TYPES.DISLIKE ? ICONS.SLIME_ACTIVE : ICONS.SLIME;
|
||||
|
||||
const likeLabel = (
|
||||
<>
|
||||
{myReaction === REACTION_TYPES.LIKE && (
|
||||
<>
|
||||
<div className="button__fire-glow" />
|
||||
<div className="button__fire-particle1" />
|
||||
<div className="button__fire-particle2" />
|
||||
<div className="button__fire-particle3" />
|
||||
<div className="button__fire-particle4" />
|
||||
<div className="button__fire-particle5" />
|
||||
<div className="button__fire-particle6" />
|
||||
</>
|
||||
)}
|
||||
<span>{formatNumberWithCommas(likeCount, 0)}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const dislikeLabel = (
|
||||
<>
|
||||
{myReaction === REACTION_TYPES.DISLIKE && (
|
||||
<>
|
||||
<div className="button__slime-stain" />
|
||||
<div className="button__slime-drop1" />
|
||||
<div className="button__slime-drop2" />
|
||||
</>
|
||||
)}
|
||||
<span>{formatNumberWithCommas(dislikeCount, 0)}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
function fetchReactions() {
|
||||
doFetchReactions(claimId);
|
||||
|
@ -104,64 +70,81 @@ export default function FileReactions(props: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className={'ratio-wrapper'}>
|
||||
<Button
|
||||
title={__('I like this')}
|
||||
requiresAuth={IS_WEB}
|
||||
authSrc="filereaction_like"
|
||||
className={classnames('button--file-action button-like', {
|
||||
'button--fire': myReaction === REACTION_TYPES.LIKE,
|
||||
})}
|
||||
label={likeLabel}
|
||||
iconSize={18}
|
||||
icon={likeIcon}
|
||||
onClick={() => doReactionLike(uri)}
|
||||
/>
|
||||
<Button
|
||||
requiresAuth={IS_WEB}
|
||||
authSrc={'filereaction_dislike'}
|
||||
title={__('I dislike this')}
|
||||
className={classnames('button--file-action button-dislike', {
|
||||
'button--slime': myReaction === REACTION_TYPES.DISLIKE,
|
||||
})}
|
||||
label={dislikeLabel}
|
||||
iconSize={18}
|
||||
icon={dislikeIcon}
|
||||
onClick={() => doReactionDislike(uri)}
|
||||
/>
|
||||
<div className="ratio-wrapper">
|
||||
<LikeButton myReaction={myReaction} reactionCount={likeCount} onClick={() => doReactionLike(uri)} />
|
||||
<DislikeButton myReaction={myReaction} reactionCount={dislikeCount} onClick={() => doReactionDislike(uri)} />
|
||||
<RatioBar likeCount={likeCount} dislikeCount={dislikeCount} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
type ReactionProps = {
|
||||
title: string,
|
||||
label: any,
|
||||
icon: string,
|
||||
isActive: boolean,
|
||||
activeClassName: string,
|
||||
type ButtonProps = {
|
||||
myReaction: ?string,
|
||||
reactionCount: number,
|
||||
onClick: () => void,
|
||||
};
|
||||
|
||||
const FileReaction = (reactionProps: ReactionProps) => {
|
||||
const { title, label, icon, isActive, activeClassName, onClick } = reactionProps;
|
||||
const LikeButton = (props: ButtonProps) => {
|
||||
const { myReaction, reactionCount, onClick } = props;
|
||||
|
||||
return (
|
||||
<Tooltip title={title} arrow={false}>
|
||||
<div className="file-reaction__tooltip-inner">
|
||||
<Button
|
||||
<FileActionButton
|
||||
title={__('I like this')}
|
||||
requiresAuth
|
||||
authSrc="filereaction_like"
|
||||
className={classnames('button--file-action', { [activeClassName]: isActive })}
|
||||
label={label}
|
||||
className={classnames('button--file-action button-like', {
|
||||
'button--fire': myReaction === REACTION_TYPES.LIKE,
|
||||
})}
|
||||
label={
|
||||
<>
|
||||
{myReaction === REACTION_TYPES.LIKE && (
|
||||
<>
|
||||
<div className="button__fire-glow" />
|
||||
<div className="button__fire-particle1" />
|
||||
<div className="button__fire-particle2" />
|
||||
<div className="button__fire-particle3" />
|
||||
<div className="button__fire-particle4" />
|
||||
<div className="button__fire-particle5" />
|
||||
<div className="button__fire-particle6" />
|
||||
</>
|
||||
)}
|
||||
<span>{formatNumberWithCommas(reactionCount, 0)}</span>
|
||||
</>
|
||||
}
|
||||
iconSize={18}
|
||||
icon={icon}
|
||||
icon={myReaction === REACTION_TYPES.LIKE ? ICONS.FIRE_ACTIVE : ICONS.FIRE}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DislikeButton = (props: ButtonProps) => {
|
||||
const { myReaction, reactionCount, onClick } = props;
|
||||
|
||||
return (
|
||||
<FileActionButton
|
||||
requiresAuth
|
||||
authSrc={'filereaction_dislike'}
|
||||
title={__('I dislike this')}
|
||||
className={classnames('button--file-action button-dislike', {
|
||||
'button--slime': myReaction === REACTION_TYPES.DISLIKE,
|
||||
})}
|
||||
label={
|
||||
<>
|
||||
{myReaction === REACTION_TYPES.DISLIKE && (
|
||||
<>
|
||||
<div className="button__slime-stain" />
|
||||
<div className="button__slime-drop1" />
|
||||
<div className="button__slime-drop2" />
|
||||
</>
|
||||
)}
|
||||
<span>{formatNumberWithCommas(reactionCount, 0)}</span>
|
||||
</>
|
||||
}
|
||||
iconSize={18}
|
||||
icon={myReaction === REACTION_TYPES.DISLIKE ? ICONS.SLIME_ACTIVE : ICONS.SLIME}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
*/
|
||||
|
|
|
@ -4,13 +4,18 @@ import {
|
|||
selectTitleForUri,
|
||||
selectClaimWasPurchasedForUri,
|
||||
selectGeoRestrictionForUri,
|
||||
selectClaimIsNsfwForUri,
|
||||
} from 'redux/selectors/claims';
|
||||
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
|
||||
import {
|
||||
makeSelectNextUrlForCollectionAndUrl,
|
||||
makeSelectPreviousUrlForCollectionAndUrl,
|
||||
selectCollectionForId,
|
||||
selectNextUrlForCollectionAndUrl,
|
||||
selectPreviousUrlForCollectionAndUrl,
|
||||
selectCollectionForIdHasClaimUrl,
|
||||
selectFirstItemUrlForCollection,
|
||||
} from 'redux/selectors/collections';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import {
|
||||
makeSelectIsPlayerFloating,
|
||||
selectPrimaryUri,
|
||||
|
@ -18,24 +23,32 @@ import {
|
|||
makeSelectFileRenderModeForUri,
|
||||
} from 'redux/selectors/content';
|
||||
import { selectClientSetting } from 'redux/selectors/settings';
|
||||
import { doClearQueueList } from 'redux/actions/collections';
|
||||
import { selectCostInfoForUri } from 'lbryinc';
|
||||
import { doUriInitiatePlay, doSetPlayingUri, doClearPlayingUri } from 'redux/actions/content';
|
||||
import { doFetchRecommendedContent } from 'redux/actions/search';
|
||||
import { withRouter } from 'react-router';
|
||||
import { selectAppDrawerOpen } from 'redux/selectors/app';
|
||||
import { selectHasAppDrawerOpen, selectMainPlayerDimensions } from 'redux/selectors/app';
|
||||
import { selectIsActiveLivestreamForUri, selectSocketConnectionForId } from 'redux/selectors/livestream';
|
||||
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
|
||||
import { isStreamPlaceholderClaim, getVideoClaimAspectRatio } from 'util/claim';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import FileRenderFloating from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { location } = props;
|
||||
const { search } = location;
|
||||
const urlParams = new URLSearchParams(search);
|
||||
const collectionSidebarId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
|
||||
|
||||
const playingUri = selectPlayingUri(state);
|
||||
const { uri, collectionId } = playingUri || {};
|
||||
const {
|
||||
uri,
|
||||
collection: { collectionId },
|
||||
} = playingUri;
|
||||
|
||||
const claim = uri && selectClaimForUri(state, uri);
|
||||
const { claim_id: claimId, signing_channel: channelClaim } = claim || {};
|
||||
const { claim_id: claimId, signing_channel: channelClaim, permanent_url } = claim || {};
|
||||
const { canonical_url: channelUrl } = channelClaim || {};
|
||||
|
||||
return {
|
||||
|
@ -47,20 +60,27 @@ const select = (state, props) => {
|
|||
title: selectTitleForUri(state, uri),
|
||||
isFloating: makeSelectIsPlayerFloating(location)(state),
|
||||
streamingUrl: makeSelectStreamingUrlForUri(uri)(state),
|
||||
floatingPlayerEnabled: selectClientSetting(state, SETTINGS.FLOATING_PLAYER),
|
||||
floatingPlayerEnabled: playingUri.source === 'queue' || selectClientSetting(state, SETTINGS.FLOATING_PLAYER),
|
||||
renderMode: makeSelectFileRenderModeForUri(uri)(state),
|
||||
videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE),
|
||||
costInfo: selectCostInfoForUri(state, uri),
|
||||
claimWasPurchased: selectClaimWasPurchasedForUri(state, uri),
|
||||
nextListUri: collectionId && makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state),
|
||||
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state),
|
||||
nextListUri: collectionId && selectNextUrlForCollectionAndUrl(state, uri, collectionId),
|
||||
previousListUri: collectionId && selectPreviousUrlForCollectionAndUrl(state, uri, collectionId),
|
||||
collectionId,
|
||||
collectionSidebarId,
|
||||
playingCollection: selectCollectionForId(state, collectionId),
|
||||
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
|
||||
videoAspectRatio: getVideoClaimAspectRatio(claim),
|
||||
socketConnection: selectSocketConnectionForId(state, claimId),
|
||||
isLivestreamClaim: isStreamPlaceholderClaim(claim),
|
||||
geoRestriction: selectGeoRestrictionForUri(state, uri),
|
||||
appDrawerOpen: selectAppDrawerOpen(state),
|
||||
appDrawerOpen: selectHasAppDrawerOpen(state),
|
||||
hasClaimInQueue:
|
||||
permanent_url && selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, permanent_url),
|
||||
mainPlayerDimensions: selectMainPlayerDimensions(state),
|
||||
firstCollectionItemUrl: selectFirstItemUrlForCollection(state, collectionId),
|
||||
isMature: selectClaimIsNsfwForUri(state, uri),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -71,6 +91,8 @@ const perform = {
|
|||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doClearPlayingUri,
|
||||
doClearQueueList,
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default withRouter(connect(select, perform)(FileRenderFloating));
|
||||
|
|
|
@ -4,8 +4,10 @@
|
|||
import { Global } from '@emotion/react';
|
||||
|
||||
import type { ElementRef } from 'react';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as RENDER_MODES from 'constants/file_render_modes';
|
||||
import { DEFAULT_INITIAL_FLOATING_POS } from 'constants/player';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import classnames from 'classnames';
|
||||
|
@ -19,7 +21,6 @@ import { onFullscreenChange } from 'util/full-screen';
|
|||
import { generateListSearchUrlParams, formatLbryChannelName } from 'util/url';
|
||||
import { useIsMobile, useIsMobileLandscape, useIsLandscapeScreen } from 'effects/use-screensize';
|
||||
import debounce from 'util/debounce';
|
||||
import { useHistory } from 'react-router';
|
||||
import { isURIEqual } from 'util/lbryURI';
|
||||
import AutoplayCountdown from 'component/autoplayCountdown';
|
||||
import usePlayNext from 'effects/use-play-next';
|
||||
|
@ -32,7 +33,8 @@ import {
|
|||
getMaxLandscapeHeight,
|
||||
getAmountNeededToCenterVideo,
|
||||
getPossiblePlayerHeight,
|
||||
} from './helper-functions';
|
||||
} from 'util/window';
|
||||
import PlaylistCard from 'component/playlistCard';
|
||||
|
||||
// scss/init/vars.scss
|
||||
// --header-height
|
||||
|
@ -62,6 +64,7 @@ type Props = {
|
|||
primaryUri: ?string,
|
||||
videoTheaterMode: boolean,
|
||||
collectionId: string,
|
||||
collectionSidebarId: ?string,
|
||||
costInfo: any,
|
||||
claimWasPurchased: boolean,
|
||||
nextListUri: string,
|
||||
|
@ -75,9 +78,16 @@ type Props = {
|
|||
isLivestreamClaim: boolean,
|
||||
geoRestriction: ?GeoRestriction,
|
||||
appDrawerOpen: boolean,
|
||||
playingCollection: Collection,
|
||||
hasClaimInQueue: boolean,
|
||||
mainPlayerDimensions: { height: number, width: number },
|
||||
firstCollectionItemUrl: ?string,
|
||||
isMature: boolean,
|
||||
doCommentSocketConnect: (string, string, string) => void,
|
||||
doCommentSocketDisconnect: (string, string) => void,
|
||||
doClearPlayingUri: () => void,
|
||||
doClearQueueList: () => void,
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
export default function FileRenderFloating(props: Props) {
|
||||
|
@ -94,6 +104,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
primaryUri,
|
||||
videoTheaterMode,
|
||||
collectionId,
|
||||
collectionSidebarId,
|
||||
costInfo,
|
||||
claimWasPurchased,
|
||||
nextListUri,
|
||||
|
@ -107,9 +118,16 @@ export default function FileRenderFloating(props: Props) {
|
|||
videoAspectRatio,
|
||||
geoRestriction,
|
||||
appDrawerOpen,
|
||||
playingCollection,
|
||||
hasClaimInQueue,
|
||||
mainPlayerDimensions,
|
||||
firstCollectionItemUrl,
|
||||
isMature,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doClearPlayingUri,
|
||||
doClearQueueList,
|
||||
doOpenModal,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
@ -120,33 +138,27 @@ export default function FileRenderFloating(props: Props) {
|
|||
const initialPlayerHeight = React.useRef();
|
||||
const resizedBetweenFloating = React.useRef();
|
||||
|
||||
const {
|
||||
location: { state },
|
||||
} = useHistory();
|
||||
const hideFloatingPlayer = state && state.hideFloatingPlayer;
|
||||
|
||||
const { uri: playingUrl, source: playingUriSource, primaryUri: playingPrimaryUri } = playingUri;
|
||||
|
||||
const isComment = playingUriSource === 'comment';
|
||||
const mainFilePlaying = Boolean(!isFloating && primaryUri && isURIEqual(uri, primaryUri));
|
||||
const noFloatingPlayer = !isFloating || !floatingPlayerEnabled || hideFloatingPlayer;
|
||||
const noFloatingPlayer = !isFloating || !floatingPlayerEnabled;
|
||||
|
||||
const [fileViewerRect, setFileViewerRect] = React.useState();
|
||||
const [wasDragging, setWasDragging] = React.useState(false);
|
||||
const [doNavigate, setDoNavigate] = React.useState(false);
|
||||
const [shouldPlayNext, setPlayNext] = React.useState(true);
|
||||
const [countdownCanceled, setCountdownCanceled] = React.useState(false);
|
||||
const [position, setPosition] = usePersistedState('floating-file-viewer:position', {
|
||||
x: -25,
|
||||
y: window.innerHeight - 400,
|
||||
});
|
||||
const relativePosRef = React.useRef({ x: 0, y: 0 });
|
||||
const [forceDisable, setForceDisable] = React.useState(false);
|
||||
const [position, setPosition] = usePersistedState('floating-file-viewer:position', DEFAULT_INITIAL_FLOATING_POS);
|
||||
const relativePosRef = React.useRef(calculateRelativePos(position.x, position.y));
|
||||
const noPlayerHeight = fileViewerRect?.height === 0;
|
||||
|
||||
const navigateUrl =
|
||||
(playingPrimaryUri || playingUrl || '') + (collectionId ? generateListSearchUrlParams(collectionId) : '');
|
||||
|
||||
const isFree = costInfo && costInfo.cost === 0;
|
||||
const isLoading = !costInfo || (!streamingUrl && !costInfo.cost);
|
||||
const canViewFile = isFree || claimWasPurchased;
|
||||
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive;
|
||||
const isReadyToPlay = isCurrentClaimLive || (isPlayable && streamingUrl);
|
||||
|
@ -158,10 +170,14 @@ export default function FileRenderFloating(props: Props) {
|
|||
// ****************************************************************************
|
||||
|
||||
const handleResize = React.useCallback(() => {
|
||||
const element = mainFilePlaying
|
||||
? document.querySelector(`.${PRIMARY_PLAYER_WRAPPER_CLASS}`)
|
||||
const filePageElement = document.querySelector(`.${PRIMARY_PLAYER_WRAPPER_CLASS}`);
|
||||
|
||||
const playingElement = mainFilePlaying
|
||||
? filePageElement
|
||||
: document.querySelector(`.${INLINE_PLAYER_WRAPPER_CLASS}`);
|
||||
|
||||
const element = playingElement || filePageElement;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
@ -198,8 +214,11 @@ export default function FileRenderFloating(props: Props) {
|
|||
|
||||
const newX = Math.round(relativePosRef.current.x * screenW);
|
||||
const newY = Math.round(relativePosRef.current.y * screenH);
|
||||
const clampPosition = clampFloatingPlayerToScreen({ x: newX, y: newY });
|
||||
|
||||
setPosition(clampFloatingPlayerToScreen(newX, newY));
|
||||
if (![clampPosition.x, clampPosition.y].some(isNaN)) {
|
||||
setPosition(clampPosition);
|
||||
}
|
||||
}, [setPosition]);
|
||||
|
||||
const clampToScreenOnResize = React.useCallback(
|
||||
|
@ -222,7 +241,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
isFloating,
|
||||
collectionId,
|
||||
shouldPlayNext,
|
||||
nextListUri,
|
||||
nextListUri || firstCollectionItemUrl,
|
||||
previousListUri,
|
||||
doNavigate,
|
||||
doUriInitiatePlay,
|
||||
|
@ -258,10 +277,11 @@ export default function FileRenderFloating(props: Props) {
|
|||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playingPrimaryUri || playingUrl || noPlayerHeight) {
|
||||
if (playingPrimaryUri || playingUrl || noPlayerHeight || collectionSidebarId) {
|
||||
handleResize();
|
||||
setCountdownCanceled(false);
|
||||
}
|
||||
}, [handleResize, playingPrimaryUri, theaterMode, playingUrl, noPlayerHeight]);
|
||||
}, [handleResize, playingPrimaryUri, theaterMode, playingUrl, noPlayerHeight, collectionSidebarId]);
|
||||
|
||||
// Listen to main-window resizing and adjust the floating player position accordingly:
|
||||
React.useEffect(() => {
|
||||
|
@ -278,7 +298,8 @@ export default function FileRenderFloating(props: Props) {
|
|||
}
|
||||
|
||||
function onWindowResize() {
|
||||
return isFloating ? clampToScreenOnResize() : handleResize();
|
||||
if (isFloating) clampToScreenOnResize();
|
||||
if (collectionSidebarId || !isFloating) handleResize();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
@ -290,7 +311,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clampToScreenOnResize, handleResize, isFloating]);
|
||||
}, [clampToScreenOnResize, handleResize, isFloating, collectionSidebarId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Initial update for relativePosRef:
|
||||
|
@ -324,15 +345,15 @@ export default function FileRenderFloating(props: Props) {
|
|||
}, [playingUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!primaryUri && !floatingPlayerEnabled && playingUrl && !playingUriSource) {
|
||||
if (primaryUri && uri && primaryUri !== uri && !floatingPlayerEnabled && playingUrl) {
|
||||
doClearPlayingUri();
|
||||
}
|
||||
}, [doClearPlayingUri, floatingPlayerEnabled, playingUriSource, playingUrl, primaryUri]);
|
||||
}, [doClearPlayingUri, floatingPlayerEnabled, playingUrl, primaryUri, uri]);
|
||||
|
||||
if (
|
||||
geoRestriction ||
|
||||
!isPlayable ||
|
||||
!uri ||
|
||||
(!isPlayable && !collectionSidebarId) ||
|
||||
(!uri && !collectionSidebarId) ||
|
||||
(isFloating && noFloatingPlayer) ||
|
||||
(collectionId && !isFloating && ((!canViewFile && !nextListUri) || countdownCanceled)) ||
|
||||
(isLivestreamClaim && !isCurrentClaimLive)
|
||||
|
@ -366,7 +387,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
let newPos = { x, y };
|
||||
|
||||
if (newPos.x !== position.x || newPos.y !== position.y) {
|
||||
newPos = clampFloatingPlayerToScreen(newPos.x, newPos.y);
|
||||
newPos = clampFloatingPlayerToScreen(newPos);
|
||||
|
||||
setPosition(newPos);
|
||||
relativePosRef.current = calculateRelativePos(newPos.x, newPos.y);
|
||||
|
@ -374,6 +395,22 @@ export default function FileRenderFloating(props: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(uri && videoAspectRatio) || collectionSidebarId ? (
|
||||
<PlayerGlobalStyles
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
theaterMode={theaterMode}
|
||||
appDrawerOpen={appDrawerOpen && !isLandscapeRotated && !isTabletLandscape}
|
||||
initialPlayerHeight={initialPlayerHeight}
|
||||
isFloating={isFloating}
|
||||
fileViewerRect={fileViewerRect || mainPlayerDimensions}
|
||||
mainFilePlaying={mainFilePlaying}
|
||||
isLandscapeRotated={isLandscapeRotated}
|
||||
isTabletLandscape={isTabletLandscape}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{uri && isPlayable && (
|
||||
<Draggable
|
||||
onDrag={handleDragMove}
|
||||
onStart={handleDragStart}
|
||||
|
@ -383,7 +420,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
bounds="parent"
|
||||
handle=".draggable"
|
||||
cancel=".button"
|
||||
disabled={noFloatingPlayer}
|
||||
disabled={noFloatingPlayer || forceDisable}
|
||||
>
|
||||
<div
|
||||
className={classnames([CONTENT_VIEWER_CLASS], {
|
||||
|
@ -408,63 +445,90 @@ export default function FileRenderFloating(props: Props) {
|
|||
: {}
|
||||
}
|
||||
>
|
||||
{uri && videoAspectRatio && fileViewerRect ? (
|
||||
<PlayerGlobalStyles
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
theaterMode={theaterMode}
|
||||
appDrawerOpen={appDrawerOpen && !isLandscapeRotated && !isTabletLandscape}
|
||||
initialPlayerHeight={initialPlayerHeight}
|
||||
isFloating={isFloating}
|
||||
fileViewerRect={fileViewerRect}
|
||||
mainFilePlaying={mainFilePlaying}
|
||||
isLandscapeRotated={isLandscapeRotated}
|
||||
isTabletLandscape={isTabletLandscape}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={classnames('content__wrapper', { 'content__wrapper--floating': isFloating })}>
|
||||
{isFloating && (
|
||||
<Button
|
||||
title={__('Close')}
|
||||
onClick={() => doSetPlayingUri({ uri: null })}
|
||||
onClick={() => {
|
||||
if (hasClaimInQueue) {
|
||||
doOpenModal(MODALS.CONFIRM, {
|
||||
title: __('Close Player'),
|
||||
subtitle: __('Are you sure you want to close the player and clear the current Queue?'),
|
||||
onConfirm: (closeModal) => {
|
||||
doClearPlayingUri();
|
||||
doClearQueueList();
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
doClearPlayingUri();
|
||||
}
|
||||
}}
|
||||
icon={ICONS.REMOVE}
|
||||
button="primary"
|
||||
className="content__floating-close"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isReadyToPlay ? (
|
||||
{isReadyToPlay && !isMature ? (
|
||||
<FileRender className={classnames({ draggable: !isMobile })} uri={uri} />
|
||||
) : collectionId && !canViewFile ? (
|
||||
) : isLoading ? (
|
||||
<LoadingScreen status={__('Loading')} />
|
||||
) : (
|
||||
(!collectionId || !canViewFile || isMature) && (
|
||||
<div className="content__loading">
|
||||
<AutoplayCountdown
|
||||
nextRecommendedUri={nextListUri}
|
||||
uri={uri}
|
||||
nextRecommendedUri={nextListUri || firstCollectionItemUrl}
|
||||
doNavigate={() => setDoNavigate(true)}
|
||||
doReplay={() => doUriInitiatePlay({ uri, collectionId }, false, isFloating)}
|
||||
doPrevious={() => {
|
||||
doReplay={() => doUriInitiatePlay({ uri, collection: { collectionId } }, false, isFloating)}
|
||||
doPrevious={
|
||||
!previousListUri
|
||||
? undefined
|
||||
: () => {
|
||||
setPlayNext(false);
|
||||
setDoNavigate(true);
|
||||
}}
|
||||
}
|
||||
}
|
||||
onCanceled={() => setCountdownCanceled(true)}
|
||||
skipPaid
|
||||
skipMature
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<LoadingScreen status={__('Loading')} />
|
||||
)
|
||||
)}
|
||||
|
||||
{isFloating && (
|
||||
<div className={classnames('content__info', { draggable: !isMobile })}>
|
||||
<div className="content-info__text">
|
||||
<div className="claim-preview__title" title={title || uri}>
|
||||
<Button label={title || uri} navigate={navigateUrl} button="link" className="content__floating-link" />
|
||||
<Button
|
||||
label={title || uri}
|
||||
navigate={navigateUrl}
|
||||
button="link"
|
||||
className="content__floating-link"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UriIndicator link uri={uri} />
|
||||
</div>
|
||||
|
||||
{playingCollection && collectionSidebarId !== collectionId && (
|
||||
<PlaylistCard
|
||||
id={collectionId}
|
||||
uri={uri}
|
||||
disableClickNavigation
|
||||
doDisablePlayerDrag={setForceDisable}
|
||||
isFloating
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -493,6 +557,8 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
|
|||
isTabletLandscape,
|
||||
} = props;
|
||||
|
||||
const justChanged = React.useRef();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobilePlayer = isMobile && !isFloating; // to avoid miniplayer -> file page only
|
||||
|
||||
|
@ -511,14 +577,30 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
|
|||
// Handles video shrink + center on mobile view
|
||||
// direct DOM manipulation due to performance for every scroll
|
||||
React.useEffect(() => {
|
||||
if (!isMobilePlayer || !mainFilePlaying || appDrawerOpen || isLandscapeRotated || isTabletLandscape) return;
|
||||
if (!isMobilePlayer || !mainFilePlaying || isLandscapeRotated || isTabletLandscape) return;
|
||||
|
||||
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
|
||||
if (viewer) viewer.style.height = `${heightForViewer}px`;
|
||||
if (viewer) {
|
||||
if (!appDrawerOpen && heightForViewer) viewer.style.height = `${heightForViewer}px`;
|
||||
|
||||
if (!appDrawerOpen) {
|
||||
const htmlEl = document.querySelector('html');
|
||||
if (htmlEl) htmlEl.scrollTop = 0;
|
||||
}
|
||||
|
||||
justChanged.current = true;
|
||||
}
|
||||
|
||||
if (appDrawerOpen) return;
|
||||
|
||||
function handleScroll() {
|
||||
const rootEl = getRootEl();
|
||||
|
||||
if (justChanged.current) {
|
||||
justChanged.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
|
||||
const videoNode = document.querySelector('.vjs-tech');
|
||||
const touchOverlay = document.querySelector('.vjs-touch-overlay');
|
||||
|
@ -548,10 +630,6 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
|
|||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
// clear the added styles on unmount
|
||||
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
|
||||
// $FlowFixMe
|
||||
if (viewer) viewer.style.height = undefined;
|
||||
const touchOverlay = document.querySelector('.vjs-touch-overlay');
|
||||
if (touchOverlay) touchOverlay.removeAttribute('style');
|
||||
|
||||
|
@ -569,9 +647,12 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
|
|||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (appDrawerOpen && videoGreaterThanLandscape && isMobilePlayer) {
|
||||
if (videoGreaterThanLandscape && isMobilePlayer) {
|
||||
const videoNode = document.querySelector('.vjs-tech');
|
||||
if (videoNode) videoNode.style.top = `${amountNeededToCenter}px`;
|
||||
if (videoNode) {
|
||||
const top = appDrawerOpen ? amountNeededToCenter : 0;
|
||||
videoNode.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile && isFloating) {
|
||||
|
@ -655,6 +736,17 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
|
|||
: undefined,
|
||||
...maxHeight,
|
||||
},
|
||||
|
||||
'.playlist-card': {
|
||||
maxHeight:
|
||||
!isMobile && !theaterMode && mainFilePlaying
|
||||
? `${heightForViewer}px`
|
||||
: isMobile
|
||||
? '100%'
|
||||
: fileViewerRect
|
||||
? `${fileViewerRect.height}px`
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -120,7 +120,7 @@ export default function FileRenderInitiator(props: Props) {
|
|||
// Wrap this in useCallback because we need to use it to the view effect
|
||||
// If we don't a new instance will be created for every render and react will think the dependencies have changed, which will add/remove the listener for every render
|
||||
const viewFile = React.useCallback(() => {
|
||||
const playingOptions = { uri, collectionId, pathname, source: undefined, commentId: undefined };
|
||||
const playingOptions = { uri, collection: { collectionId }, pathname, source: undefined, commentId: undefined };
|
||||
|
||||
if (parentCommentId) {
|
||||
playingOptions.source = 'comment';
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doResolveUri } from 'redux/actions/claims';
|
||||
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
||||
import { selectHasResolvedClaimForUri, selectThumbnailForUri } from 'redux/selectors/claims';
|
||||
import CardMedia from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
export default connect(select, { doResolveUri })(CardMedia);
|
||||
return {
|
||||
hasResolvedClaim: uri ? selectHasResolvedClaimForUri(state, uri) : undefined,
|
||||
thumbnailFromClaim: selectThumbnailForUri(state, uri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doResolveUri,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(CardMedia);
|
||||
|
|
|
@ -9,16 +9,21 @@ type Props = {
|
|||
fallback: ?string,
|
||||
children?: Node,
|
||||
className?: string,
|
||||
small?: boolean,
|
||||
};
|
||||
|
||||
const Thumb = (props: Props) => {
|
||||
const { thumb, fallback, children, className } = props;
|
||||
const { thumb, fallback, children, className, small } = props;
|
||||
const thumbnailRef = React.useRef(null);
|
||||
|
||||
useLazyLoading(thumbnailRef, fallback || '');
|
||||
|
||||
return (
|
||||
<div ref={thumbnailRef} data-background-image={thumb} className={classnames('media__thumb', className)}>
|
||||
<div
|
||||
ref={thumbnailRef}
|
||||
data-background-image={thumb}
|
||||
className={classnames('media__thumb', { className, 'media__thumb--small': small })}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
|
@ -11,62 +11,76 @@ import {
|
|||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import { getImageProxyUrl, getThumbnailCdnUrl } from 'util/thumbnail';
|
||||
import React from 'react';
|
||||
import FreezeframeWrapper from './FreezeframeWrapper';
|
||||
import Placeholder from './placeholder.png';
|
||||
import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
|
||||
import classnames from 'classnames';
|
||||
import Thumb from './thumb';
|
||||
import Thumb from './internal/thumb';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
uri?: string,
|
||||
tileLayout?: boolean,
|
||||
thumbnail: ?string, // externally sourced image
|
||||
children?: Node,
|
||||
allowGifs: boolean,
|
||||
claim: ?StreamClaim,
|
||||
doResolveUri: (string) => void,
|
||||
className?: string,
|
||||
small?: boolean,
|
||||
forcePlaceholder?: boolean,
|
||||
// -- redux --
|
||||
hasResolvedClaim: ?boolean, // undefined if uri is not given (irrelevant); boolean otherwise.
|
||||
thumbnailFromClaim: ?string,
|
||||
doResolveUri: (uri: string) => void,
|
||||
};
|
||||
|
||||
function FileThumbnail(props: Props) {
|
||||
const {
|
||||
claim,
|
||||
uri,
|
||||
tileLayout,
|
||||
doResolveUri,
|
||||
thumbnail: rawThumbnail,
|
||||
children,
|
||||
allowGifs = false,
|
||||
className,
|
||||
small,
|
||||
forcePlaceholder,
|
||||
// -- redux --
|
||||
hasResolvedClaim,
|
||||
thumbnailFromClaim,
|
||||
doResolveUri,
|
||||
} = props;
|
||||
|
||||
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
|
||||
const thumbnailFromClaim =
|
||||
uri && claim && claim.value && claim.value.thumbnail ? claim.value.thumbnail.url : undefined;
|
||||
const thumbnail = passedThumbnail || thumbnailFromClaim;
|
||||
|
||||
const hasResolvedClaim = claim !== undefined;
|
||||
const isGif = thumbnail && thumbnail.endsWith('gif');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
|
||||
const thumbnail = passedThumbnail || thumbnailFromClaim;
|
||||
const isGif = thumbnail && thumbnail.endsWith('gif');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasResolvedClaim && uri && !passedThumbnail) {
|
||||
if (hasResolvedClaim === false && uri && !passedThumbnail) {
|
||||
doResolveUri(uri);
|
||||
}
|
||||
}, [hasResolvedClaim, uri, doResolveUri, passedThumbnail]);
|
||||
}, [hasResolvedClaim, passedThumbnail, doResolveUri, uri]);
|
||||
|
||||
if (!allowGifs && isGif) {
|
||||
const url = getImageProxyUrl(thumbnail);
|
||||
|
||||
return (
|
||||
<FreezeframeWrapper src={url} className={classnames('media__thumb', className)}>
|
||||
url && (
|
||||
<FreezeframeWrapper
|
||||
small={small}
|
||||
src={url}
|
||||
className={classnames('media__thumb', className, {
|
||||
'media__thumb--resolving': hasResolvedClaim === false,
|
||||
'media__thumb--small': small,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</FreezeframeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const fallback = MISSING_THUMB_DEFAULT ? getThumbnailCdnUrl({ thumbnail: MISSING_THUMB_DEFAULT }) : undefined;
|
||||
|
||||
let url = thumbnail || (hasResolvedClaim ? Placeholder : '');
|
||||
// @if TARGET='web'
|
||||
let url = thumbnail || (hasResolvedClaim ? MISSING_THUMB_DEFAULT : '');
|
||||
// Pass image urls through a compression proxy
|
||||
if (thumbnail) {
|
||||
if (isGif) {
|
||||
|
@ -80,21 +94,22 @@ function FileThumbnail(props: Props) {
|
|||
});
|
||||
}
|
||||
}
|
||||
// @endif
|
||||
|
||||
const thumbnailUrl = url ? url.replace(/'/g, "\\'") : '';
|
||||
|
||||
if (hasResolvedClaim || thumbnailUrl) {
|
||||
if (hasResolvedClaim || thumbnailUrl || (forcePlaceholder && !uri)) {
|
||||
return (
|
||||
<Thumb thumb={thumbnailUrl} fallback={fallback} className={className}>
|
||||
<Thumb small={small} thumb={thumbnailUrl || MISSING_THUMB_DEFAULT} fallback={fallback} className={className}>
|
||||
{children}
|
||||
</Thumb>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames('media__thumb', className, {
|
||||
'media__thumb--resolving': !hasResolvedClaim,
|
||||
'media__thumb--resolving': hasResolvedClaim === false,
|
||||
'media__thumb--small': small,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import { makeSelectCollectionForIdHasClaimUrl } from 'redux/selectors/collections';
|
||||
import { selectCollectionForIdHasClaimUrl } from 'redux/selectors/collections';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import FileWatchLaterLink from './view';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
return {
|
||||
hasClaimInWatchLater: makeSelectCollectionForIdHasClaimUrl(COLLECTIONS_CONSTS.WATCH_LATER_ID, uri)(state),
|
||||
hasClaimInWatchLater: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.WATCH_LATER_ID, uri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doToast: (props) => dispatch(doToast(props)),
|
||||
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),
|
||||
});
|
||||
const perform = {
|
||||
doPlaylistAddAndAllowPlaying,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(FileWatchLaterLink);
|
||||
|
|
|
@ -3,32 +3,49 @@ import * as ICONS from 'constants/icons';
|
|||
import React, { useRef } from 'react';
|
||||
import Button from 'component/button';
|
||||
import useHover from 'effects/use-hover';
|
||||
import { useHistory } from 'react-router';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
focusable: boolean,
|
||||
hasClaimInWatchLater: boolean,
|
||||
doToast: ({ message: string }) => void,
|
||||
doCollectionEdit: (string, any) => void,
|
||||
doPlaylistAddAndAllowPlaying: (params: {
|
||||
uri: string,
|
||||
collectionName: string,
|
||||
collectionId: string,
|
||||
push: (uri: string) => void,
|
||||
}) => void,
|
||||
};
|
||||
|
||||
function FileWatchLaterLink(props: Props) {
|
||||
const { uri, hasClaimInWatchLater, doToast, doCollectionEdit, focusable = true } = props;
|
||||
const { uri, hasClaimInWatchLater, focusable = true, doPlaylistAddAndAllowPlaying } = props;
|
||||
|
||||
const {
|
||||
push,
|
||||
location: { search },
|
||||
} = useHistory();
|
||||
|
||||
const buttonRef = useRef();
|
||||
let isHovering = useHover(buttonRef);
|
||||
|
||||
function handleWatchLater(e) {
|
||||
e.preventDefault();
|
||||
doToast({
|
||||
message: hasClaimInWatchLater ? __('Item removed from Watch Later') : __('Item added to Watch Later'),
|
||||
linkText: !hasClaimInWatchLater && __('See All'),
|
||||
linkTarget: !hasClaimInWatchLater && '/list/watchlater',
|
||||
});
|
||||
doCollectionEdit(COLLECTIONS_CONSTS.WATCH_LATER_ID, {
|
||||
uris: [uri],
|
||||
remove: hasClaimInWatchLater,
|
||||
type: 'playlist',
|
||||
if (e) e.preventDefault();
|
||||
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, COLLECTIONS_CONSTS.WATCH_LATER_ID);
|
||||
|
||||
doPlaylistAddAndAllowPlaying({
|
||||
uri,
|
||||
collectionName: COLLECTIONS_CONSTS.WATCH_LATER_NAME,
|
||||
collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID,
|
||||
push: (pushUri) =>
|
||||
push({
|
||||
pathname: formatLbryUrlForWeb(pushUri),
|
||||
search: urlParams.toString(),
|
||||
state: { collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID, forceAutoplay: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -39,19 +56,18 @@ function FileWatchLaterLink(props: Props) {
|
|||
const label = !hasClaimInWatchLater ? __('Watch Later') : __('Remove');
|
||||
|
||||
return (
|
||||
<div className="claim-preview__hover-actions second-item">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
requiresAuth={IS_WEB}
|
||||
requiresAuth
|
||||
title={title}
|
||||
label={label}
|
||||
className="button--file-action"
|
||||
icon={
|
||||
(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) ||
|
||||
(isHovering ? ICONS.COMPLETED : ICONS.TIME)
|
||||
}
|
||||
icon={(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) || ICONS.TIME}
|
||||
onClick={(e) => handleWatchLater(e)}
|
||||
tabIndex={focusable ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
9
ui/component/formNewCollection/index.js
Normal file
9
ui/component/formNewCollection/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import FormNewCollection from './view';
|
||||
import { doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
|
||||
|
||||
const perform = {
|
||||
doPlaylistAddAndAllowPlaying,
|
||||
};
|
||||
|
||||
export default connect(null, perform)(FormNewCollection);
|
120
ui/component/formNewCollection/view.jsx
Normal file
120
ui/component/formNewCollection/view.jsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import type { ElementRef } from 'react';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as KEYCODES from 'constants/keycodes';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import { FormField } from 'component/common/form';
|
||||
import { useHistory } from 'react-router';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import Button from 'component/button';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
onlyCreate?: boolean,
|
||||
closeForm: (newCollectionName?: string) => void,
|
||||
// -- redux --
|
||||
doPlaylistAddAndAllowPlaying: (params: {
|
||||
uri: string,
|
||||
collectionName: string,
|
||||
createNew: boolean,
|
||||
push: (uri: string) => void,
|
||||
}) => void,
|
||||
};
|
||||
|
||||
function FormNewCollection(props: Props) {
|
||||
const { uri, onlyCreate, closeForm, doPlaylistAddAndAllowPlaying } = props;
|
||||
|
||||
const {
|
||||
push,
|
||||
location: { search },
|
||||
} = useHistory();
|
||||
|
||||
const buttonref: ElementRef<any> = React.useRef();
|
||||
const newCollectionName = React.useRef('');
|
||||
|
||||
const [disabled, setDisabled] = React.useState(true);
|
||||
|
||||
function handleNameInput(e) {
|
||||
const { value } = e.target;
|
||||
newCollectionName.current = value;
|
||||
setDisabled(value.length === 0);
|
||||
}
|
||||
|
||||
function handleAddCollection() {
|
||||
const name = newCollectionName.current;
|
||||
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, COLLECTIONS_CONSTS.WATCH_LATER_ID);
|
||||
|
||||
doPlaylistAddAndAllowPlaying({
|
||||
uri,
|
||||
collectionName: name,
|
||||
createNew: true,
|
||||
push: (pushUri) =>
|
||||
push({
|
||||
pathname: formatLbryUrlForWeb(pushUri),
|
||||
search: urlParams.toString(),
|
||||
state: { collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID, forceAutoplay: true },
|
||||
}),
|
||||
});
|
||||
|
||||
closeForm(name);
|
||||
}
|
||||
|
||||
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
|
||||
if (e.keyCode === KEYCODES.ENTER) {
|
||||
e.preventDefault();
|
||||
buttonref.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
function onTextareaFocus() {
|
||||
window.addEventListener('keydown', altEnterListener);
|
||||
}
|
||||
|
||||
function onTextareaBlur() {
|
||||
window.removeEventListener('keydown', altEnterListener);
|
||||
}
|
||||
|
||||
function handleClearNew() {
|
||||
closeForm();
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
autoFocus
|
||||
type="text"
|
||||
name="new_collection"
|
||||
label={__('New Playlist Title')}
|
||||
placeholder={__(COLLECTIONS_CONSTS.PLACEHOLDER)}
|
||||
onFocus={onTextareaFocus}
|
||||
onBlur={onTextareaBlur}
|
||||
inputButton={
|
||||
<>
|
||||
<Button
|
||||
button="alt"
|
||||
icon={ICONS.COMPLETED}
|
||||
title={__('Confirm')}
|
||||
className="button-toggle"
|
||||
disabled={disabled}
|
||||
onClick={handleAddCollection}
|
||||
ref={buttonref}
|
||||
/>
|
||||
{!onlyCreate && (
|
||||
<Button
|
||||
button="alt"
|
||||
className="button-toggle"
|
||||
icon={ICONS.REMOVE}
|
||||
title={__('Cancel')}
|
||||
onClick={handleClearNew}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onChange={handleNameInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormNewCollection;
|
|
@ -11,6 +11,7 @@ import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
|
|||
import FileRenderInitiator from 'component/fileRenderInitiator';
|
||||
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as DRAWERS from 'constants/drawer_types';
|
||||
import SwipeableDrawer from 'component/swipeableDrawer';
|
||||
import DrawerExpandButton from 'component/swipeableDrawerExpand';
|
||||
import LivestreamMenu from 'component/livestreamChatLayout/livestream-menu';
|
||||
|
@ -104,6 +105,8 @@ export default function LivestreamLayout(props: Props) {
|
|||
{isMobile && !isLandscapeRotated && !hideComments && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SwipeableDrawer
|
||||
startOpen
|
||||
type={DRAWERS.CHAT}
|
||||
title={
|
||||
<ChatModeSelector
|
||||
superChats={superChats}
|
||||
|
@ -131,7 +134,7 @@ export default function LivestreamLayout(props: Props) {
|
|||
/>
|
||||
</SwipeableDrawer>
|
||||
|
||||
<DrawerExpandButton label={__('Open Live Chat')} />
|
||||
<DrawerExpandButton icon={ICONS.CHAT} label={__('Open Live Chat')} type={DRAWERS.CHAT} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
|
|
55
ui/component/playlistCard/index.js
Normal file
55
ui/component/playlistCard/index.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { connect } from 'react-redux';
|
||||
import PlaylistCard from './view';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import {
|
||||
selectUrlsForCollectionId,
|
||||
selectNameForCollectionId,
|
||||
selectCollectionIsMine,
|
||||
selectIsCollectionPrivateForId,
|
||||
selectPublishedCollectionChannelNameForId,
|
||||
selectIndexForUrlInCollection,
|
||||
selectCollectionLengthForId,
|
||||
selectCollectionIsEmptyForId,
|
||||
selectCollectionForId,
|
||||
} from 'redux/selectors/collections';
|
||||
import { selectPlayingUri } from 'redux/selectors/content';
|
||||
import { doCollectionEdit, doClearQueueList } from 'redux/actions/collections';
|
||||
import { doClearPlayingCollection } from 'redux/actions/content';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { id: collectionId } = props;
|
||||
|
||||
const {
|
||||
uri: playingUri,
|
||||
collection: { collectionId: playingCollectionId },
|
||||
} = selectPlayingUri(state);
|
||||
|
||||
const playingCurrentPlaylist = collectionId === playingCollectionId;
|
||||
const { permanent_url: playingItemUrl } = playingCurrentPlaylist ? selectClaimForUri(state, playingUri) || {} : {};
|
||||
const playingItemIndex = selectIndexForUrlInCollection(state, playingItemUrl, playingCollectionId, true);
|
||||
|
||||
return {
|
||||
playingItemUrl,
|
||||
playingCurrentPlaylist,
|
||||
collectionUrls: selectUrlsForCollectionId(state, collectionId),
|
||||
collectionName: selectNameForCollectionId(state, collectionId),
|
||||
isMyCollection: selectCollectionIsMine(state, collectionId),
|
||||
isPrivateCollection: selectIsCollectionPrivateForId(state, collectionId),
|
||||
publishedCollectionName: selectPublishedCollectionChannelNameForId(state, collectionId),
|
||||
playingItemIndex: playingItemIndex !== null ? playingItemIndex + 1 : 0,
|
||||
collectionLength: selectCollectionLengthForId(state, collectionId),
|
||||
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
|
||||
hasCollectionById: collectionId && Boolean(selectCollectionForId(state, collectionId)),
|
||||
playingCollectionId,
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doCollectionEdit,
|
||||
doClearPlayingCollection,
|
||||
doClearQueueList,
|
||||
doOpenModal,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(PlaylistCard);
|
18
ui/component/playlistCard/internal/loopButton/index.js
Normal file
18
ui/component/playlistCard/internal/loopButton/index.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { connect } from 'react-redux';
|
||||
import LoopButton from './view';
|
||||
import { selectListIsLoopedForId } from 'redux/selectors/content';
|
||||
import { doToggleLoopList } from 'redux/actions/content';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { id: collectionId } = props;
|
||||
|
||||
return {
|
||||
loop: selectListIsLoopedForId(state, collectionId),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doToggleLoopList,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(LoopButton);
|
28
ui/component/playlistCard/internal/loopButton/view.jsx
Normal file
28
ui/component/playlistCard/internal/loopButton/view.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
|
||||
type Props = {
|
||||
id: string,
|
||||
// -- redux --
|
||||
loop: boolean,
|
||||
doToggleLoopList: (params: { collectionId: string }) => void,
|
||||
};
|
||||
|
||||
const LoopButton = (props: Props) => {
|
||||
const { id, loop, doToggleLoopList } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
button="alt"
|
||||
className="button--alt-no-style button-toggle"
|
||||
title={__('Loop')}
|
||||
icon={ICONS.REPEAT}
|
||||
iconColor={loop ? 'blue' : undefined}
|
||||
onClick={() => doToggleLoopList({ collectionId: id })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoopButton;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue