Bulk actions support (#1418)

This commit is contained in:
Markos Gogoulos
2025-11-11 11:32:54 +02:00
committed by GitHub
parent 2a0cb977f2
commit e80590a3aa
160 changed files with 14100 additions and 1797 deletions

View File

@@ -4,6 +4,8 @@
#page-profile-media,
#page-profile-playlists,
#page-profile-about,
#page-profile-shared-by-me,
#page-profile-shared-with-me,
#page-liked.profile-page-liked,
#page-history.profile-page-history {
.page-main {
@@ -33,6 +35,7 @@
li {
a {
color: var(--profile-page-nav-link-text-color);
text-transform: none;
&:hover {
color: var(--profile-page-nav-link-hover-text-color);
@@ -189,49 +192,151 @@
display: block;
}
a.edit-channel,
a.edit-profile {
a.edit-channel-icon {
position: absolute;
}
a.edit-channel,
a.edit-profile,
.delete-profile-wrap > button {
text-decoration: none;
font-size: 13px;
font-weight: 400;
color: #fff;
border: 0;
line-height: inherit;
padding: 6px 12px;
border-radius: 1px;
background-color: var(--brand-color, var(--default-brand-color));
@media screen and (min-width: 710px) {
padding: 8px 16px;
}
}
a.edit-channel,
a.edit-profile {
}
a.edit-channel {
top: 16px;
right: 16px;
text-decoration: none;
color: #fff;
border: 0;
line-height: 1;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(40, 167, 69, 0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@media screen and (min-width: 710px) {
right: 24px;
}
.material-icons {
font-size: 22px;
line-height: 1;
}
&:hover {
background-color: rgba(40, 167, 69, 1);
color: #fff;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
&:active {
transform: scale(0.98);
}
.dark_theme & {
background-color: rgba(40, 167, 69, 0.9);
color: #fff;
&:hover {
background-color: rgba(40, 167, 69, 1);
color: #fff;
}
}
}
a.edit-profile {
top: 0;
right: 0;
a.edit-profile-icon {
text-decoration: none;
color: #666;
border: 0;
line-height: 1;
padding: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
.material-icons {
font-size: 20px;
line-height: 1;
}
@media screen and (max-width: 480px) {
width: 30px;
height: 30px;
.material-icons {
font-size: 18px;
}
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
transform: scale(1.05);
}
&:active {
transform: scale(0.98);
}
.dark_theme & {
background-color: rgba(255, 255, 255, 0.1);
color: #aaa;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
}
}
}
.delete-profile-wrap > button {
text-decoration: none;
color: #fff;
border: 0;
line-height: 1;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(220, 53, 69, 0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
.material-icons {
font-size: 22px;
line-height: 1;
}
&:hover {
background-color: rgba(220, 53, 69, 1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transform: scale(1.05);
}
&:active {
transform: scale(0.98);
}
.dark_theme & {
background-color: rgba(255, 107, 107, 0.9);
&:hover {
background-color: rgba(255, 107, 107, 1);
}
}
}
.delete-profile-wrap {
position: absolute;
top: 16px;
@@ -250,6 +355,13 @@
padding-left: 16px;
padding-right: 16px;
// Reduce padding on mobile
@media screen and (max-width: 480px) {
padding-top: 12px;
padding-left: 8px;
padding-right: 8px;
}
@media screen and (min-width: 710px) {
padding-left: 24px;
padding-right: 24px;
@@ -297,6 +409,20 @@
font-weight: 400;
line-height: 1.25;
margin: 0;
@media screen and (max-width: 480px) {
font-size: 20px;
}
}
.profile-name-edit-wrapper {
display: flex;
align-items: center;
gap: 12px;
@media screen and (max-width: 480px) {
gap: 8px;
}
}
.profile-info-inner {
@@ -341,6 +467,9 @@
max-width: 100%;
margin: 0 auto;
clear: both;
display: flex;
align-items: center;
position: relative;
.sliding-sidebar & {
transition-property: width;
@@ -348,54 +477,115 @@
}
}
&.items-list-outer .previous-slide,
&.items-list-outer .next-slide {
top: 4px;
bottom: 4px;
padding: 0 !important;
.previous-slide,
.next-slide {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px !important;
margin: 0;
background-color: var(--profile-page-header-bg-color);
height: $_authorPage-navHeight;
flex-shrink: 0;
z-index: 2;
.circle-icon-button {
margin: 0;
background-color: var(--profile-page-header-bg-color);
background-color: transparent;
}
}
&.items-list-outer .previous-slide {
left: -0.75em;
left: -1px;
.previous-slide {
padding-right: 8px !important;
}
&.items-list-outer .next-slide {
right: -0.75em;
right: -1px;
.next-slide {
padding-left: 8px !important;
}
ul {
position: relative;
width: 100%;
float: left;
flex: 1;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: nowrap;
align-items: center;
font-size: 0;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
min-width: 0; // Allow flex item to shrink
// Hide scrollbar but keep functionality
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
li {
position: relative;
display: inline-block;
text-align: center;
vertical-align: bottom;
flex-shrink: 0;
a {
display: block;
line-height: $_authorPage-navHeight;
width: 109px;
width: auto;
padding: 0 16px;
text-decoration: none;
text-transform: uppercase;
text-transform: none !important;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.007px;
// Mobile optimization - remove padding and reduce width
@media screen and (max-width: 768px) {
width: auto;
font-size: 11px;
padding: 0 8px;
margin: 0;
white-space: nowrap;
}
@media screen and (max-width: 480px) {
width: auto;
font-size: 10px;
padding: 0 6px;
margin: 0;
white-space: nowrap;
}
@media screen and (max-width: 360px) {
width: auto;
font-size: 9px;
padding: 0 4px;
margin: 0;
letter-spacing: 0;
white-space: nowrap;
}
}
// Ensure icon buttons are visible on mobile
&.media-search,
&.media-filters-toggle,
&.media-tags-toggle,
&.media-sorting-toggle {
@media screen and (max-width: 768px) {
display: inline-flex;
align-items: center;
span {
font-size: 14px;
}
}
}
&.active {
@@ -411,36 +601,54 @@
}
&.media-search {
display: flex !important;
align-items: center;
> * {
position: relative;
display: table;
float: left;
display: flex;
align-items: center;
width: auto;
height: 3rem;
> span {
display: table-cell;
vertical-align: middle;
display: inline-flex;
align-items: center;
}
}
form {
display: flex;
align-items: center;
}
button {
background-color: transparent;
background-color: rgba(0, 0, 0, 0);
}
input[type='text'] {
width: 178px;
max-width: 178px;
padding-left: 0;
padding-right: 0;
padding-left: 8px;
padding-right: 8px;
font-weight: 500;
border-width: 0 0 2px;
border-color: var(--profile-page-nav-link-text-color);
background-color: transparent;
background-color: rgba(0, 0, 0, 0);
box-shadow: none;
font-size: 14px;
color: var(--profile-page-nav-link-text-color);
&::placeholder {
color: var(--profile-page-nav-link-text-color);
opacity: 0.7;
}
&:focus {
border-bottom-color: var(--profile-page-nav-link-active-after-bg-color);
outline: none;
}
}
}
@@ -454,7 +662,7 @@
}
.profile-nav {
z-index: +2;
z-index: 3;
position: fixed;
top: var(--header-height);
left: 0;
@@ -480,6 +688,8 @@
#page-profile-media &,
#page-profile-about &,
#page-profile-playlists &,
#page-profile-shared-by-me &,
#page-profile-shared-with-me &,
#page-liked.profile-page-liked &,
#page-history.profile-page-history & {
padding-bottom: 0;

View File

@@ -5,7 +5,6 @@ import { LinksContext, MemberContext, SiteContext } from '../../utils/contexts/'
import { PageStore, ProfilePageStore } from '../../utils/stores/';
import { PageActions, ProfilePageActions } from '../../utils/actions/';
import { CircleIconButton, PopupMain } from '../_shared';
import ItemsInlineSlider from '../item-list/includes/itemLists/ItemsInlineSlider';
import { translateString } from '../../utils/helpers/';
class ProfileSearchBar extends React.PureComponent {
@@ -26,6 +25,7 @@ class ProfileSearchBar extends React.PureComponent {
this.updateTimeout = null;
this.pendingUpdate = false;
this.justShown = false;
}
updateQuery(value) {
@@ -45,10 +45,11 @@ class ProfileSearchBar extends React.PureComponent {
onChange(ev) {
this.pendingEvent = ev;
const newValue = ev.target.value || '';
this.setState(
{
queryVal: ev.target.value || '',
queryVal: newValue,
},
function () {
if (this.updateTimeout) {
@@ -57,8 +58,11 @@ class ProfileSearchBar extends React.PureComponent {
this.pendingEvent = null;
// Only trigger search if 3+ characters or empty (to reset)
if ('function' === typeof this.props.onQueryChange) {
this.props.onQueryChange(this.state.queryVal);
if (newValue.length >= 3 || newValue.length === 0) {
this.props.onQueryChange(newValue);
}
}
this.updateTimeout = setTimeout(
@@ -100,10 +104,15 @@ class ProfileSearchBar extends React.PureComponent {
}
onInputBlur() {
// Don't hide immediately after showing to prevent race condition
if (this.justShown) {
return;
}
this.hideForm();
}
showForm() {
this.justShown = true;
this.setState(
{
visibleForm: true,
@@ -112,6 +121,10 @@ class ProfileSearchBar extends React.PureComponent {
if ('function' === typeof this.props.toggleSearchField) {
this.props.toggleSearchField();
}
// Reset the flag after a short delay
setTimeout(() => {
this.justShown = false;
}, 200);
}
);
}
@@ -137,24 +150,56 @@ class ProfileSearchBar extends React.PureComponent {
}
render() {
const hasSearchText = this.state.queryVal && this.state.queryVal.length > 0;
// Determine the correct action URL based on page type
let actionUrl = LinksContext._currentValue.profile.media;
if (this.props.type === 'shared_by_me') {
actionUrl = LinksContext._currentValue.profile.shared_by_me;
} else if (this.props.type === 'shared_with_me') {
actionUrl = LinksContext._currentValue.profile.shared_with_me;
}
if (!this.state.visibleForm) {
return (
<div>
<span>
<CircleIconButton buttonShadow={false} onClick={this.showForm}>
<i className="material-icons">search</i>
</CircleIconButton>
</span>
</div>
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.showForm}>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">search</i>
</CircleIconButton>
{hasSearchText ? (
<span style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}></span>
) : null}
</span>
);
}
return (
<form method="get" action={LinksContext._currentValue.profile.media} onSubmit={this.onFormSubmit}>
<span>
<form method="get" action={actionUrl} onSubmit={this.onFormSubmit}>
<span style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">search</i>
</CircleIconButton>
{hasSearchText ? (
<span style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}></span>
) : null}
</span>
<span>
<input
@@ -177,6 +222,7 @@ class ProfileSearchBar extends React.PureComponent {
ProfileSearchBar.propTypes = {
onQueryChange: PropTypes.func,
type: PropTypes.string,
};
ProfileSearchBar.defaultProps = {};
@@ -207,12 +253,11 @@ class NavMenuInlineTabs extends React.PureComponent {
displayPrev: false,
};
this.inlineSlider = null;
this.nextSlide = this.nextSlide.bind(this);
this.prevSlide = this.prevSlide.bind(this);
this.updateSlider = this.updateSlider.bind(this, false);
this.updateSlider = this.updateSlider.bind(this);
this.updateSliderButtonsView = this.updateSliderButtonsView.bind(this);
this.onToggleSearchField = this.onToggleSearchField.bind(this);
@@ -266,44 +311,57 @@ class NavMenuInlineTabs extends React.PureComponent {
componentDidMount() {
this.updateSlider();
if (this.refs.itemsListWrap) {
this.refs.itemsListWrap.addEventListener('scroll', this.updateSliderButtonsView.bind(this));
}
}
componentWillUnmount() {
if (this.refs.itemsListWrap) {
this.refs.itemsListWrap.removeEventListener('scroll', this.updateSliderButtonsView.bind(this));
}
}
nextSlide() {
this.inlineSlider.nextSlide();
this.updateSliderButtonsView();
this.inlineSlider.scrollToCurrentSlide();
if (!this.refs.itemsListWrap) return;
const scrollAmount = this.refs.itemsListWrap.offsetWidth * 0.7; // Scroll 70% of visible width
this.refs.itemsListWrap.scrollLeft += scrollAmount;
setTimeout(() => this.updateSliderButtonsView(), 50);
}
prevSlide() {
this.inlineSlider.previousSlide();
this.updateSliderButtonsView();
this.inlineSlider.scrollToCurrentSlide();
if (!this.refs.itemsListWrap) return;
const scrollAmount = this.refs.itemsListWrap.offsetWidth * 0.7; // Scroll 70% of visible width
this.refs.itemsListWrap.scrollLeft -= scrollAmount;
setTimeout(() => this.updateSliderButtonsView(), 50);
}
updateSlider(afterItemsUpdate) {
if (!this.inlineSlider) {
this.inlineSlider = new ItemsInlineSlider(this.refs.itemsListWrap, '.profile-nav ul li');
}
this.inlineSlider.updateDataState(document.querySelectorAll('.profile-nav ul li').length, true, !afterItemsUpdate);
this.updateSliderButtonsView();
if (this.pendingChangeSlide) {
this.pendingChangeSlide = false;
this.inlineSlider.scrollToCurrentSlide();
}
}
updateSliderButtonsView() {
if (!this.refs.itemsListWrap) return;
const container = this.refs.itemsListWrap;
const scrollLeft = container.scrollLeft;
const scrollWidth = container.scrollWidth;
const clientWidth = container.clientWidth;
// Show prev arrow if we can scroll left
const canScrollLeft = scrollLeft > 1;
// Show next arrow if we can scroll right
const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
this.setState({
displayPrev: this.inlineSlider.hasPreviousSlide(),
displayNext: this.inlineSlider.hasNextSlide(),
displayPrev: canScrollLeft,
displayNext: canScrollRight,
});
}
onToggleSearchField() {
this.updateSlider();
setTimeout(() => this.updateSlider(), 100);
}
render() {
@@ -322,9 +380,25 @@ class NavMenuInlineTabs extends React.PureComponent {
<InlineTab
id="media"
isActive={'media' === this.props.type}
label={translateString('Media')}
label={translateString(this.userIsAuthor ? 'Media I own' : 'Media')}
link={LinksContext._currentValue.profile.media}
/>
{this.userIsAuthor ? (
<InlineTab
id="shared_by_me"
isActive={'shared_by_me' === this.props.type}
label={translateString('Shared by me')}
link={LinksContext._currentValue.profile.shared_by_me}
/>
) : null}
{this.userIsAuthor ? (
<InlineTab
id="shared_with_me"
isActive={'shared_with_me' === this.props.type}
label={translateString('Shared with me')}
link={LinksContext._currentValue.profile.shared_with_me}
/>
) : null}
{MemberContext._currentValue.can.saveMedia ? (
<InlineTab
@@ -351,9 +425,74 @@ class NavMenuInlineTabs extends React.PureComponent {
/>
) : null}
<li className="media-search">
<ProfileSearchBar onQueryChange={this.props.onQueryChange} toggleSearchField={this.onToggleSearchField} />
</li>
{!['about', 'playlists'].includes(this.props.type) ? (
<li className="media-search">
<ProfileSearchBar onQueryChange={this.props.onQueryChange} toggleSearchField={this.onToggleSearchField} type={this.props.type} />
</li>
) : null}
{this.props.onToggleFiltersClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-filters-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleFiltersClick} title={translateString('Filters')}>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">filter_list</i>
</CircleIconButton>
{this.props.hasActiveFilters ? (
<span style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}></span>
) : null}
</span>
</li>
) : null}
{this.props.onToggleTagsClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-tags-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleTagsClick} title={translateString('Tags')}>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">local_offer</i>
</CircleIconButton>
{this.props.hasActiveTags ? (
<span style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}></span>
) : null}
</span>
</li>
) : null}
{this.props.onToggleSortingClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-sorting-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleSortingClick} title={translateString('Sort By')}>
<CircleIconButton buttonShadow={false}>
<i className="material-icons">swap_vert</i>
</CircleIconButton>
{this.props.hasActiveSort ? (
<span style={{
position: 'absolute',
top: '8px',
right: '8px',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)',
border: '2px solid white',
}}></span>
) : null}
</span>
</li>
) : null}
</ul>
{this.state.displayNext ? this.nextBtn : null}
@@ -366,6 +505,12 @@ class NavMenuInlineTabs extends React.PureComponent {
NavMenuInlineTabs.propTypes = {
type: PropTypes.string.isRequired,
onQueryChange: PropTypes.func,
onToggleFiltersClick: PropTypes.func,
onToggleTagsClick: PropTypes.func,
onToggleSortingClick: PropTypes.func,
hasActiveFilters: PropTypes.bool,
hasActiveTags: PropTypes.bool,
hasActiveSort: PropTypes.bool,
};
function AddBannerButton(props) {
@@ -375,8 +520,8 @@ function AddBannerButton(props) {
link = '/edit-channel.html';
}
return (
<a href={link} className="edit-channel" title="Add banner">
ADD BANNER
<a href={link} className="edit-channel-icon" title="Add banner">
<i className="material-icons">add_photo_alternate</i>
</a>
);
}
@@ -388,8 +533,8 @@ function EditBannerButton(props) {
link = '/edit-channel.html';
}
return (
<a href={link} className="edit-channel" title="Edit banner">
EDIT BANNER
<a href={link} className="edit-channel-icon" title="Edit banner">
<i className="material-icons">edit</i>
</a>
);
}
@@ -402,8 +547,8 @@ function EditProfileButton(props) {
}
return (
<a href={link} className="edit-profile" title="Edit profile">
EDIT PROFILE
<a href={link} className="edit-profile-icon" title="Edit profile">
<i className="material-icons">edit</i>
</a>
);
}
@@ -528,11 +673,11 @@ export default function ProfilePagesHeader(props) {
></span>
) : null}
{userCanDeleteProfile ? (
{userCanDeleteProfile && !userIsAuthor ? (
<span className="delete-profile-wrap">
<PopupTrigger contentRef={popupContentRef}>
<button className="delete-profile" title="">
REMOVE PROFILE
<button className="delete-profile" title="Remove profile">
<i className="material-icons">delete</i>
</button>
</PopupTrigger>
@@ -556,7 +701,7 @@ export default function ProfilePagesHeader(props) {
</span>
) : null}
{userCanEditProfile ? (
{userCanEditProfile && userIsAuthor ? (
props.author.banner_thumbnail_url ? (
<EditBannerButton link={ProfilePageStore.get('author-data').default_channel_edit_url} />
) : (
@@ -571,14 +716,28 @@ export default function ProfilePagesHeader(props) {
<div className="profile-info-inner">
<div>{props.author.thumbnail_url ? <img src={props.author.thumbnail_url} alt="" /> : null}</div>
<div>
{props.author.name ? <h1>{props.author.name}</h1> : null}
{userCanEditProfile ? <EditProfileButton link={ProfilePageStore.get('author-data').edit_url} /> : null}
{props.author.name ? (
<div className="profile-name-edit-wrapper">
<h1>{props.author.name}</h1>
{userCanEditProfile && !userIsAuthor ? <EditProfileButton link={ProfilePageStore.get('author-data').edit_url} /> : null}
</div>
) : null}
</div>
</div>
</div>
) : null}
<NavMenuInlineTabs ref={profileNavRef} type={props.type} onQueryChange={props.onQueryChange} />
<NavMenuInlineTabs
ref={profileNavRef}
type={props.type}
onQueryChange={props.onQueryChange}
onToggleFiltersClick={props.onToggleFiltersClick}
onToggleTagsClick={props.onToggleTagsClick}
onToggleSortingClick={props.onToggleSortingClick}
hasActiveFilters={props.hasActiveFilters}
hasActiveTags={props.hasActiveTags}
hasActiveSort={props.hasActiveSort}
/>
</div>
</div>
);
@@ -588,6 +747,12 @@ ProfilePagesHeader.propTypes = {
author: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
onQueryChange: PropTypes.func,
onToggleFiltersClick: PropTypes.func,
onToggleTagsClick: PropTypes.func,
onToggleSortingClick: PropTypes.func,
hasActiveFilters: PropTypes.bool,
hasActiveTags: PropTypes.bool,
hasActiveSort: PropTypes.bool,
};
ProfilePagesHeader.defaultProps = {