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

@@ -143,6 +143,18 @@ const PAGES = {
component: 'ProfilePlaylistsPage',
global: { profileId: DEV_SAMPLE_DATA.profileId },
},
'profile-shared-by-me': {
id: 'profile-shared-by-me',
title: 'Profile - Shared by me',
component: 'ProfileSharedByMePage',
global: { profileId: DEV_SAMPLE_DATA.profileId },
},
'profile-shared-with-me': {
id: 'profile-shared-with-me',
title: 'Profile - Shared with me',
component: 'ProfileSharedWithMePage',
global: { profileId: DEV_SAMPLE_DATA.profileId },
},
};
const STATIC_PAGES = {

View File

@@ -341,4 +341,19 @@ body.dark_theme {
--user-action-form-inner-input-border-color: #303030;
--user-action-form-inner-input-text-color: rgba(255, 255, 255, 0.88);
--user-action-form-inner-input-bg-color: #121212;
/* ################################################## */
.media-edit-nav {
background-color: #1a1a1a !important;
a {
color: #cccccc !important;
&[style*="font-weight: bold"] {
color: #ffffff !important;
border-bottom-color: #ffffff !important;
}
}
}
}

View File

@@ -0,0 +1,433 @@
@import '../../css/config/index.scss';
.category-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.category-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.category-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.category-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.category-modal-content {
display: flex;
gap: 24px;
padding: 24px;
flex: 1;
overflow: visible;
}
.category-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: visible;
h3 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
.dark_theme & {
color: #fff;
}
}
h4 {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
color: #666;
.dark_theme & {
color: #aaa;
}
}
}
.info-tooltip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #ccc;
color: #fff;
font-size: 12px;
font-weight: bold;
cursor: help;
transition: background-color 0.2s ease;
&:hover {
background-color: #999;
}
.dark_theme & {
background-color: #555;
&:hover {
background-color: #777;
}
}
}
.category-modal {
.available-categories {
margin-top: 16px;
flex: 1;
display: flex;
flex-direction: column;
overflow: visible;
}
.category-list {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
background-color: #f9f9f9;
min-height: 100px;
.dark_theme & {
background-color: #333;
border-color: #555;
}
&.scrollable {
max-height: 300px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
.dark_theme & {
background: #2a2a2a;
}
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
&:hover {
background: #aaa;
}
.dark_theme & {
background: #555;
&:hover {
background: #666;
}
}
}
}
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
&.clickable {
cursor: pointer;
&:hover {
background-color: #f0f7ff;
border-color: var(--default-theme-color, #009933);
.dark_theme & {
background-color: #3a3a3a;
}
}
}
&.marked-for-removal {
background-color: #ffe0e0;
border-color: #ffaaaa;
opacity: 0.7;
.dark_theme & {
background-color: #4a2a2a;
border-color: #aa5555;
}
span {
text-decoration: line-through;
}
}
span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.add-btn,
.remove-btn {
background: none;
border: none;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.add-btn {
color: var(--default-theme-color, #009933);
&:hover {
background-color: rgba(0, 153, 51, 0.1);
}
.dark_theme & {
color: #66bb66;
&:hover {
background-color: rgba(102, 187, 102, 0.2);
}
}
}
.remove-btn {
color: #dc3545;
&:hover {
background-color: rgba(220, 53, 69, 0.1);
color: #c82333;
}
.dark_theme & {
color: #ff6b6b;
&:hover {
background-color: rgba(255, 107, 107, 0.2);
color: #ff8787;
}
}
}
.empty-message,
.loading-message {
padding: 40px 20px;
text-align: center;
color: #999;
font-size: 14px;
font-style: italic;
.dark_theme & {
color: #666;
}
}
}
.category-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.category-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.category-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.category-btn-proceed {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}
// Responsive design for smaller screens
@media (max-width: 768px) {
.category-modal {
max-width: 95%;
}
.category-modal-content {
flex-direction: column;
gap: 16px;
}
.category-list.scrollable {
max-height: 150px;
}
}

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import './BulkActionCategoryModal.scss';
import { translateString } from '../utils/helpers/';
interface Category {
title: string;
uid: string;
}
interface BulkActionCategoryModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const [existingCategories, setExistingCategories] = useState<Category[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
const [categoriesToRemove, setCategoriesToRemove] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (isOpen && selectedMediaIds.length > 0) {
fetchData();
} else {
// Reset state when modal closes
setExistingCategories([]);
setAllCategories([]);
setCategoriesToAdd([]);
setCategoriesToRemove([]);
}
}, [isOpen, selectedMediaIds]);
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch existing categories (intersection - categories all selected media belong to)
const existingResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'category_membership',
media_ids: selectedMediaIds,
}),
});
if (!existingResponse.ok) {
throw new Error(translateString('Failed to fetch existing categories'));
}
const existingData = await existingResponse.json();
const existing = existingData.results || [];
// Fetch all categories
const allResponse = await fetch('/api/v1/categories');
if (!allResponse.ok) {
throw new Error(translateString('Failed to fetch all categories'));
}
const allData = await allResponse.json();
const all = allData.results || allData;
setExistingCategories(existing);
setAllCategories(all);
} catch (error) {
console.error('Error fetching categories:', error);
onError(translateString('Failed to load categories'));
} finally {
setIsLoading(false);
}
};
const addCategoryToList = (category: Category) => {
if (!categoriesToAdd.some((c) => c.uid === category.uid)) {
setCategoriesToAdd([...categoriesToAdd, category]);
}
};
const removeCategoryFromAddList = (category: Category) => {
setCategoriesToAdd(categoriesToAdd.filter((c) => c.uid !== category.uid));
};
const markCategoryForRemoval = (category: Category) => {
if (!categoriesToRemove.some((c) => c.uid === category.uid)) {
setCategoriesToRemove([...categoriesToRemove, category]);
}
};
const unmarkCategoryForRemoval = (category: Category) => {
setCategoriesToRemove(categoriesToRemove.filter((c) => c.uid !== category.uid));
};
const handleProceed = async () => {
setIsProcessing(true);
try {
// First, add categories if any
if (categoriesToAdd.length > 0) {
const uidsToAdd = categoriesToAdd.map((c) => c.uid);
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'add_to_category',
media_ids: selectedMediaIds,
category_uids: uidsToAdd,
}),
});
if (!addResponse.ok) {
throw new Error(translateString('Failed to add categories'));
}
}
// Then, remove categories if any
if (categoriesToRemove.length > 0) {
const uidsToRemove = categoriesToRemove.map((c) => c.uid);
const removeResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'remove_from_category',
media_ids: selectedMediaIds,
category_uids: uidsToRemove,
}),
});
if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove categories'));
}
}
onSuccess(translateString('Successfully updated categories'));
onCancel();
} catch (error) {
console.error('Error processing categories:', error);
onError(translateString('Failed to update categories. Please try again.'));
} finally {
setIsProcessing(false);
}
};
// Get categories for left panel (all categories minus those already existing)
const getLeftPanelCategories = () => {
return allCategories.filter(
(cat) => !existingCategories.some((existing) => existing.uid === cat.uid)
);
};
// Get categories for right panel ("Add to" - existing + newly added)
const getRightPanelCategories = () => {
// Combine existing categories with newly added ones
const combined = [...existingCategories, ...categoriesToAdd];
return combined;
};
if (!isOpen) return null;
const leftPanelCategories = getLeftPanelCategories();
const rightPanelCategories = getRightPanelCategories();
return (
<div className="category-modal-overlay">
<div className="category-modal">
<div className="category-modal-header">
<h2>{translateString('Add / Remove from Categories')}</h2>
<button className="category-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="category-modal-content">
<div className="category-panel">
<h3>{translateString('Categories')}</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{leftPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('All categories already added')}</div>
) : (
leftPanelCategories.map((category) => (
<div
key={category.uid}
className="category-item clickable"
onClick={() => addCategoryToList(category)}
>
<span>{category.title}</span>
<button className="add-btn">+</button>
</div>
))
)}
</div>
)}
</div>
<div className="category-panel">
<h3>
{translateString('Add to')}
{selectedMediaIds.length > 1 && (
<span className="info-tooltip" title={translateString('The intersection of categories in the selected media is shown')}>
?
</span>
)}
</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div>
) : (
<div className="category-list scrollable">
{rightPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('No categories')}</div>
) : (
rightPanelCategories.map((category) => {
const isExisting = existingCategories.some((c) => c.uid === category.uid);
const isMarkedForRemoval = categoriesToRemove.some((c) => c.uid === category.uid);
return (
<div key={category.uid} className={`category-item ${isMarkedForRemoval ? 'marked-for-removal' : ''}`}>
<span>{category.title}</span>
<button
className="remove-btn"
onClick={() => {
if (isExisting) {
// This is an existing category - mark/unmark for removal
isMarkedForRemoval ? unmarkCategoryForRemoval(category) : markCategoryForRemoval(category);
} else {
// This is a newly added category - remove from add list
removeCategoryFromAddList(category);
}
}}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove category') : translateString('Remove from list')}
>
{isMarkedForRemoval ? '↺' : '×'}
</button>
</div>
);
})
)}
</div>
)}
</div>
</div>
<div className="category-modal-footer">
<button className="category-btn category-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="category-btn category-btn-proceed"
onClick={handleProceed}
disabled={isProcessing || (categoriesToAdd.length === 0 && categoriesToRemove.length === 0)}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,276 @@
@import '../../css/config/index.scss';
.change-owner-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.change-owner-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 80vh;
min-height: 400px;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.change-owner-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.change-owner-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.change-owner-modal-content {
padding: 24px;
flex: 1;
overflow: visible;
display: flex;
flex-direction: column;
gap: 16px;
}
.search-box-wrapper {
position: relative;
}
.search-box {
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.dark_theme & {
background-color: #333;
border-color: #555;
color: #fff;
&:focus {
border-color: var(--default-theme-color, #009933);
}
}
}
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 4px;
max-height: 250px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
.dark_theme & {
background-color: #333;
border-color: #555;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
}
.search-result-item {
padding: 10px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 14px;
&:hover {
background-color: #e8f4ff;
.dark_theme & {
background-color: #444;
}
}
&:not(:last-child) {
border-bottom: 1px solid #eee;
.dark_theme & {
border-bottom-color: #444;
}
}
}
.selected-user {
padding: 12px 16px;
background-color: #e8f4ff;
border: 2px solid var(--default-theme-color, #009933);
border-radius: 4px;
font-size: 14px;
font-weight: 500;
color: #333;
.dark_theme & {
background-color: #1a3a52;
color: #fff;
}
span {
display: block;
}
}
.change-owner-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.change-owner-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.change-owner-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.change-owner-btn-submit {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import './BulkActionChangeOwnerModal.scss';
import { translateString } from '../utils/helpers/';
interface User {
name: string;
username: string;
email?: string;
}
interface BulkActionChangeOwnerModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setSearchTerm('');
setSearchResults([]);
setSelectedUser(null);
}
}, [isOpen]);
const searchUsers = async (name: string) => {
if (!name.trim()) {
setSearchResults([]);
return;
}
try {
const response = await fetch(`/api/v1/users?name=${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(translateString('Failed to search users'));
}
const data = await response.json();
setSearchResults(data.results || data);
} catch (error) {
console.error('Error searching users:', error);
}
};
const handleSearchChange = (value: string) => {
setSearchTerm(value);
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Set new timeout for debounced search
const timeout = setTimeout(() => {
searchUsers(value);
}, 300);
setSearchTimeout(timeout);
};
const handleUserSelect = (user: User) => {
setSelectedUser(user);
setSearchTerm(user.name + ' - ' + (user.email || user.username));
setSearchResults([]);
};
const handleSubmit = async () => {
if (!selectedUser) {
onError(translateString('Please select a user'));
return;
}
setIsProcessing(true);
try {
const response = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'change_owner',
media_ids: selectedMediaIds,
owner: selectedUser.username,
}),
});
if (!response.ok) {
throw new Error(translateString('Failed to change owner'));
}
const data = await response.json();
onSuccess(data.detail || translateString('Successfully changed owner'));
onCancel();
} catch (error) {
console.error('Error changing owner:', error);
onError(translateString('Failed to change owner. Please try again.'));
} finally {
setIsProcessing(false);
}
};
if (!isOpen) return null;
return (
<div className="change-owner-modal-overlay">
<div className="change-owner-modal">
<div className="change-owner-modal-header">
<h2>{translateString('Select Owner')}</h2>
<button className="change-owner-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="change-owner-modal-content">
<div className="search-box-wrapper">
<div className="search-box">
<input
type="text"
placeholder={translateString('Search for user...')}
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>
{searchResults.length > 0 && (
<div className="search-results">
{searchResults.slice(0, 10).map((user) => (
<div
key={user.username}
className="search-result-item"
onClick={() => handleUserSelect(user)}
>
{user.name} - {user.email || user.username}
</div>
))}
</div>
)}
</div>
{selectedUser && (
<div className="selected-user">
<span>{translateString('Selected')}: {selectedUser.name} - {selectedUser.email || selectedUser.username}</span>
</div>
)}
</div>
<div className="change-owner-modal-footer">
<button className="change-owner-btn change-owner-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="change-owner-btn change-owner-btn-submit"
onClick={handleSubmit}
disabled={isProcessing || !selectedUser}
>
{isProcessing ? translateString('Processing...') : translateString('Submit')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,133 @@
@import '../../css/config/index.scss';
.bulk-action-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.bulk-action-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.bulk-action-modal-content {
padding: 24px;
h3 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
p {
margin: 0 0 24px;
font-size: 15px;
line-height: 1.5;
color: #555;
.dark_theme & {
color: #ccc;
}
}
}
.bulk-action-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.bulk-action-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.bulk-action-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover {
background-color: #555;
}
}
}
.bulk-action-btn-proceed {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import './BulkActionConfirmModal.scss';
import { translateString } from '../utils/helpers/';
interface BulkActionConfirmModalProps {
isOpen: boolean;
message: string;
onCancel: () => void;
onProceed: () => void;
}
export const BulkActionConfirmModal: React.FC<BulkActionConfirmModalProps> = ({
isOpen,
message,
onCancel,
onProceed,
}) => {
if (!isOpen) return null;
return (
<div className="bulk-action-modal-overlay" onClick={onCancel}>
<div className="bulk-action-modal" onClick={(e) => e.stopPropagation()}>
<div className="bulk-action-modal-content">
<h3>{translateString('Confirm Action')}</h3>
<p>{message}</p>
<div className="bulk-action-modal-buttons">
<button className="bulk-action-btn bulk-action-btn-cancel" onClick={onCancel}>
{translateString('Cancel')}
</button>
<button className="bulk-action-btn bulk-action-btn-proceed" onClick={onProceed}>
{translateString('Confirm')}
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,466 @@
@import '../../css/config/index.scss';
.permission-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.permission-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.permission-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.permission-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.permission-modal-content {
display: flex;
gap: 24px;
padding: 24px;
flex: 1;
overflow: visible;
}
.permission-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Allows flex items to shrink below content size
overflow: visible;
h3 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
.dark_theme & {
color: #fff;
}
}
}
.info-tooltip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #ccc;
color: #fff;
font-size: 12px;
font-weight: bold;
cursor: help;
transition: background-color 0.2s ease;
&:hover {
background-color: #999;
}
.dark_theme & {
background-color: #555;
&:hover {
background-color: #777;
}
}
}
.search-box-wrapper {
position: relative !important;
margin-bottom: 12px;
z-index: 1001 !important;
overflow: visible !important;
}
.search-box {
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.dark_theme & {
background-color: #333;
border-color: #555;
color: #fff;
&:focus {
border-color: var(--default-theme-color, #009933);
}
}
}
}
.search-results {
position: absolute !important;
top: 100% !important;
left: 0 !important;
right: 0 !important;
background-color: white !important;
border: 1px solid #ddd !important;
border-radius: 4px !important;
margin-top: 4px !important;
max-height: 200px !important;
overflow-y: auto !important;
z-index: 10001 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
.dark_theme & {
background-color: #333 !important;
border-color: #555 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
}
.search-result-item {
padding: 10px 12px !important;
cursor: pointer !important;
transition: background-color 0.2s ease;
font-size: 14px !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
color: #333 !important;
&:hover {
background-color: #e8f4ff !important;
.dark_theme & {
background-color: #444 !important;
}
}
&:not(:last-child) {
border-bottom: 1px solid #eee !important;
.dark_theme & {
border-bottom-color: #444 !important;
color: #fff !important;
}
}
.dark_theme & {
color: #fff !important;
}
}
.user-list {
flex: 1;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
background-color: #f9f9f9;
max-height: 400px; // Approximately 10 rows at 40px per row
.dark_theme & {
background-color: #333;
border-color: #555;
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
.dark_theme & {
background: #2a2a2a;
}
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
&:hover {
background: #aaa;
}
.dark_theme & {
background: #555;
&:hover {
background: #666;
}
}
}
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
&.marked-for-removal {
background-color: #ffe0e0;
border-color: #ffaaaa;
opacity: 0.7;
.dark_theme & {
background-color: #4a2a2a;
border-color: #aa5555;
}
span {
text-decoration: line-through;
}
}
span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: rgba(220, 53, 69, 0.1);
color: #c82333;
}
.dark_theme & {
color: #ff6b6b;
&:hover {
background-color: rgba(255, 107, 107, 0.2);
color: #ff8787;
}
}
}
.empty-message,
.loading-message {
padding: 40px 20px;
text-align: center;
color: #999;
font-size: 14px;
font-style: italic;
.dark_theme & {
color: #666;
}
}
.permission-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.permission-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.permission-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.permission-btn-proceed {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}
// Responsive design for smaller screens
@media (max-width: 768px) {
.permission-modal {
max-width: 95%;
}
.permission-modal-content {
flex-direction: column;
gap: 16px;
}
.user-list {
max-height: 200px;
}
}

View File

@@ -0,0 +1,340 @@
import React, { useState, useEffect } from 'react';
import './BulkActionPermissionModal.scss';
import { translateString } from '../utils/helpers/';
interface User {
name: string;
username: string;
email?: string;
}
interface BulkActionPermissionModalProps {
isOpen: boolean;
permissionType: 'viewer' | 'editor' | 'owner' | null;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps> = ({
isOpen,
permissionType,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [existingSearchTerm, setExistingSearchTerm] = useState('');
const [usersToAdd, setUsersToAdd] = useState<Array<{ username: string; display: string }>>([]);
const [usersToRemove, setUsersToRemove] = useState<string[]>([]);
const [searchResults, setSearchResults] = useState<User[]>([]);
const [addSearchTerm, setAddSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isOpen && permissionType && selectedMediaIds.length > 0) {
fetchExistingUsers();
} else {
// Reset state when modal closes
setExistingUsers([]);
setExistingSearchTerm('');
setUsersToAdd([]);
setUsersToRemove([]);
setSearchResults([]);
setAddSearchTerm('');
}
}, [isOpen, permissionType, selectedMediaIds]);
const fetchExistingUsers = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'get_ownership',
media_ids: selectedMediaIds,
ownership_type: permissionType,
}),
});
if (!response.ok) {
throw new Error(translateString('Failed to fetch existing users'));
}
const data = await response.json();
setExistingUsers(data.results || []);
} catch (error) {
console.error('Error fetching existing users:', error);
onError(translateString('Failed to load existing permissions'));
} finally {
setIsLoading(false);
}
};
const searchUsers = async (name: string) => {
if (!name.trim()) {
setSearchResults([]);
return;
}
try {
const response = await fetch(`/api/v1/users?name=${encodeURIComponent(name)}&exclude_self=True`);
if (!response.ok) {
throw new Error(translateString('Failed to search users'));
}
const data = await response.json();
// API returns paginated response with results array
const users = data.results || [];
setSearchResults(Array.isArray(users) ? users : []);
} catch (error) {
console.error('Error searching users:', error);
setSearchResults([]);
}
};
const handleAddSearchChange = (value: string) => {
setAddSearchTerm(value);
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Only search if 3 or more characters
if (value.trim().length < 3) {
setSearchResults([]);
return;
}
// Set new timeout for debounced search
const timeout = setTimeout(() => {
searchUsers(value);
}, 300);
setSearchTimeout(timeout);
};
const addUserToList = (username: string, name: string, email?: string) => {
const userDisplay = `${name} - ${email || username}`;
if (!usersToAdd.some(u => u.username === username) && !existingUsers.includes(userDisplay)) {
setUsersToAdd([...usersToAdd, { username, display: userDisplay }]);
setAddSearchTerm('');
setSearchResults([]);
}
};
const removeUserFromAddList = (username: string) => {
setUsersToAdd(usersToAdd.filter((u) => u.username !== username));
};
const markUserForRemoval = (user: string) => {
if (!usersToRemove.includes(user)) {
setUsersToRemove([...usersToRemove, user]);
}
};
const unmarkUserForRemoval = (user: string) => {
setUsersToRemove(usersToRemove.filter((u) => u !== user));
};
const extractUsername = (userDisplay: string): string => {
// For existing users from API, extract username from "Name - username/email" format
// Note: This assumes the username is after the last ' - ' separator
const parts = userDisplay.split(' - ');
return parts.length > 1 ? parts[parts.length - 1] : userDisplay;
};
const handleProceed = async () => {
setIsProcessing(true);
try {
// First, add users if any
if (usersToAdd.length > 0) {
const usernamesToAdd = usersToAdd.map(u => u.username);
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'set_ownership',
media_ids: selectedMediaIds,
ownership_type: permissionType,
users: usernamesToAdd,
}),
});
if (!addResponse.ok) {
throw new Error(translateString('Failed to add users'));
}
}
// Then, remove users if any
if (usersToRemove.length > 0) {
const usernamesToRemove = usersToRemove.map(extractUsername);
const removeResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'remove_ownership',
media_ids: selectedMediaIds,
ownership_type: permissionType,
users: usernamesToRemove,
}),
});
if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove users'));
}
}
const permissionLabel = permissionType === 'viewer' ? translateString('Co-Viewers') : permissionType === 'editor' ? translateString('Co-Editors') : translateString('Co-Owners');
onSuccess(`${translateString('Successfully updated')} ${permissionLabel}`);
onCancel();
} catch (error) {
console.error('Error processing permissions:', error);
onError(translateString('Failed to update permissions. Please try again.'));
} finally {
setIsProcessing(false);
}
};
const filteredExistingUsers = existingUsers.filter((user) =>
user.toLowerCase().includes(existingSearchTerm.toLowerCase())
);
if (!isOpen) return null;
const permissionLabel = permissionType === 'viewer' ? translateString('Co-Viewers') : permissionType === 'editor' ? translateString('Co-Editors') : translateString('Co-Owners');
return (
<div className="permission-modal-overlay">
<div className="permission-modal">
<div className="permission-modal-header">
<h2>{translateString('Manage')} {permissionLabel}</h2>
<button className="permission-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="permission-modal-content">
<div className="permission-panel">
<h3>{translateString('Users')}</h3>
<div className="search-box-wrapper">
<div className="search-box">
<input
type="text"
placeholder={translateString('Search users to add (min 3 characters)...')}
value={addSearchTerm}
onChange={(e) => handleAddSearchChange(e.target.value)}
/>
</div>
{searchResults.length > 0 && (
<div className="search-results">
{searchResults.slice(0, 10).map((user) => (
<div
key={user.username}
className="search-result-item"
onClick={() => addUserToList(user.username, user.name, user.email)}
>
{user.name} - {user.email || user.username}
</div>
))}
</div>
)}
</div>
<div className="user-list">
{usersToAdd.length === 0 ? (
<div className="empty-message">{translateString('No users to add')}</div>
) : (
usersToAdd.map((user) => (
<div key={user.username} className="user-item">
<span>{user.display}</span>
<button className="remove-btn" onClick={() => removeUserFromAddList(user.username)}>
×
</button>
</div>
))
)}
</div>
</div>
<div className="permission-panel">
<h3>
{permissionType === 'viewer' ? translateString('Existing co-viewers') : permissionType === 'editor' ? translateString('Existing co-editors') : translateString('Existing co-owners')}
{selectedMediaIds.length > 1 && (
<span className="info-tooltip" title={translateString('The intersection of users in the selected media is shown')}>
?
</span>
)}
</h3>
<div className="search-box">
<input
type="text"
placeholder={translateString('Filter existing users...')}
value={existingSearchTerm}
onChange={(e) => setExistingSearchTerm(e.target.value)}
/>
</div>
{isLoading ? (
<div className="loading-message">{translateString('Loading existing users...')}</div>
) : (
<div className="user-list">
{filteredExistingUsers.length === 0 ? (
<div className="empty-message">{translateString('No existing')} {permissionLabel.toLowerCase()}</div>
) : (
filteredExistingUsers.map((user) => {
const isMarkedForRemoval = usersToRemove.includes(user);
return (
<div key={user} className={`user-item ${isMarkedForRemoval ? 'marked-for-removal' : ''}`}>
<span>{user}</span>
<button
className="remove-btn"
onClick={() => (isMarkedForRemoval ? unmarkUserForRemoval(user) : markUserForRemoval(user))}
title={isMarkedForRemoval ? translateString('Undo removal') : translateString('Remove user')}
>
{isMarkedForRemoval ? '↺' : '×'}
</button>
</div>
);
})
)}
</div>
)}
</div>
</div>
<div className="permission-modal-footer">
<button className="permission-btn permission-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="permission-btn permission-btn-proceed"
onClick={handleProceed}
disabled={isProcessing || (usersToAdd.length === 0 && usersToRemove.length === 0)}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,567 @@
@import '../../css/config/index.scss';
.playlist-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.playlist-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.playlist-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.playlist-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.playlist-modal-content {
display: flex;
gap: 24px;
padding: 24px;
flex: 1;
overflow: hidden;
}
.playlist-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
h3 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
.dark_theme & {
color: #fff;
}
}
}
.info-tooltip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #ccc;
color: #fff;
font-size: 12px;
font-weight: bold;
cursor: help;
transition: background-color 0.2s ease;
&:hover {
background-color: #999;
}
.dark_theme & {
background-color: #555;
&:hover {
background-color: #777;
}
}
}
.search-box {
margin-bottom: 12px;
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.dark_theme & {
background-color: #333;
border-color: #555;
color: #fff;
&:focus {
border-color: var(--default-theme-color, #009933);
}
}
}
}
.playlist-list {
flex: 1;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
background-color: #f9f9f9;
max-height: 400px;
.dark_theme & {
background-color: #333;
border-color: #555;
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
.dark_theme & {
background: #2a2a2a;
}
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
&:hover {
background: #aaa;
}
.dark_theme & {
background: #555;
&:hover {
background: #666;
}
}
}
}
.playlist-list .playlist-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
&:hover {
background-color: #f0f7ff;
border-color: var(--default-theme-color, #009933);
.dark_theme & {
background-color: #3a3a3a;
}
}
span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.playlist-item-disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f5f5f5;
.dark_theme & {
background-color: #1a1a1a;
}
&:hover {
background-color: #f5f5f5;
border-color: #e0e0e0;
.dark_theme & {
background-color: #1a1a1a;
border-color: #444;
}
}
}
}
.add-btn {
background: none;
border: none;
color: #28a745;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: rgba(40, 167, 69, 0.1);
color: #218838;
}
.dark_theme & {
color: #4caf50;
&:hover {
background-color: rgba(76, 175, 80, 0.2);
color: #66bb6a;
}
}
}
.remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: rgba(220, 53, 69, 0.1);
color: #c82333;
}
.dark_theme & {
color: #ff6b6b;
&:hover {
background-color: rgba(255, 107, 107, 0.2);
color: #ff8787;
}
}
}
.create-playlist-btn {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
background-color: var(--default-theme-color, #009933);
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}
.create-playlist-form {
padding: 12px;
background-color: #f0f7ff;
border: 2px solid var(--default-theme-color, #009933);
border-radius: 4px;
margin-bottom: 12px;
.dark_theme & {
background-color: #1a3a52;
border-color: var(--default-theme-color, #009933);
}
input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 8px;
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.dark_theme & {
background-color: #2a2a2a;
border-color: #555;
color: #fff;
&:focus {
border-color: var(--default-theme-color, #009933);
}
}
}
}
.create-playlist-buttons {
display: flex;
gap: 8px;
button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.create-btn {
background-color: #28a745;
color: white;
&:hover {
background-color: #218838;
}
.dark_theme & {
background-color: #4caf50;
&:hover {
background-color: #45a049;
}
}
}
.cancel-btn {
background-color: #6c757d;
color: white;
&:hover {
background-color: #5a6268;
}
.dark_theme & {
background-color: #555;
&:hover {
background-color: #666;
}
}
}
}
.empty-message,
.loading-message {
padding: 40px 20px;
text-align: center;
color: #999;
font-size: 14px;
font-style: italic;
.dark_theme & {
color: #666;
}
}
.playlist-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.playlist-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.playlist-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.playlist-btn-proceed {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}
// Responsive design for smaller screens
@media (max-width: 768px) {
.playlist-modal {
max-width: 95%;
}
.playlist-modal-content {
flex-direction: column;
gap: 16px;
}
.playlist-list {
max-height: 200px;
}
}

View File

@@ -0,0 +1,342 @@
import React, { useState, useEffect } from 'react';
import './BulkActionPlaylistModal.scss';
import { translateString } from '../utils/helpers/';
interface Playlist {
id?: number;
friendly_token: string;
title: string;
}
interface BulkActionPlaylistModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
username: string;
}
export const BulkActionPlaylistModal: React.FC<BulkActionPlaylistModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
username,
}) => {
const [availablePlaylists, setAvailablePlaylists] = useState<Playlist[]>([]);
const [selectedPlaylists, setSelectedPlaylists] = useState<Playlist[]>([]);
const [originalSelectedPlaylists, setOriginalSelectedPlaylists] = useState<Playlist[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isCreatingPlaylist, setIsCreatingPlaylist] = useState(false);
const [newPlaylistName, setNewPlaylistName] = useState('');
useEffect(() => {
if (isOpen && selectedMediaIds.length > 0) {
fetchData();
} else {
// Reset state when modal closes
setAvailablePlaylists([]);
setSelectedPlaylists([]);
setOriginalSelectedPlaylists([]);
setSearchTerm('');
setIsCreatingPlaylist(false);
setNewPlaylistName('');
}
}, [isOpen, selectedMediaIds]);
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch user's playlists
const playlistsResponse = await fetch(`/api/v1/playlists?author=${encodeURIComponent(username)}`);
if (!playlistsResponse.ok) {
throw new Error(translateString('Failed to fetch playlists'));
}
const playlistsData = await playlistsResponse.json();
const allPlaylists: Playlist[] = playlistsData.results || [];
// Fetch existing membership
const membershipResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'playlist_membership',
media_ids: selectedMediaIds,
}),
});
if (!membershipResponse.ok) {
throw new Error(translateString('Failed to fetch playlist membership'));
}
const membershipData = await membershipResponse.json();
const existingPlaylists: Playlist[] = membershipData.results || [];
// Set selected playlists (those that already contain all media)
setSelectedPlaylists(existingPlaylists);
setOriginalSelectedPlaylists(existingPlaylists);
// Keep all playlists in available list (we'll show selected ones as disabled)
setAvailablePlaylists(allPlaylists);
} catch (error) {
console.error('Error fetching data:', error);
onError(translateString('Failed to load playlists'));
} finally {
setIsLoading(false);
}
};
const handlePlaylistSelect = (playlist: Playlist) => {
// Add to selected (don't remove from available)
if (!selectedPlaylists.some((p) => p.friendly_token === playlist.friendly_token)) {
setSelectedPlaylists([...selectedPlaylists, playlist]);
}
};
const handlePlaylistRemove = (playlist: Playlist) => {
// Remove from selected
setSelectedPlaylists(selectedPlaylists.filter((p) => p.friendly_token !== playlist.friendly_token));
};
const handleCreatePlaylist = async () => {
if (!newPlaylistName.trim()) {
return;
}
try {
const response = await fetch('/api/v1/playlists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
title: newPlaylistName.trim(),
}),
});
if (!response.ok) {
throw new Error(translateString('Failed to create playlist'));
}
const newPlaylist = await response.json();
// Add to available playlists
setAvailablePlaylists([...availablePlaylists, newPlaylist]);
// Reset create form
setNewPlaylistName('');
setIsCreatingPlaylist(false);
} catch (error) {
console.error('Error creating playlist:', error);
}
};
const handleProceed = async () => {
setIsProcessing(true);
try {
// Determine which playlists to add (new in selected, not in original)
const originalTokens = new Set(originalSelectedPlaylists.map((p) => p.friendly_token));
const currentTokens = new Set(selectedPlaylists.map((p) => p.friendly_token));
const toAdd = selectedPlaylists.filter((p) => !originalTokens.has(p.friendly_token));
const toRemove = originalSelectedPlaylists.filter((p) => !currentTokens.has(p.friendly_token));
// Add to playlists
if (toAdd.length > 0) {
const playlistIds = toAdd.map((p) => p.id).filter((id): id is number => id !== undefined);
if (playlistIds.length > 0) {
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'add_to_playlist',
media_ids: selectedMediaIds,
playlist_ids: playlistIds,
}),
});
if (!addResponse.ok) {
throw new Error(translateString('Failed to add media to playlists'));
}
}
}
// Remove from playlists
if (toRemove.length > 0) {
const playlistIds = toRemove.map((p) => p.id).filter((id): id is number => id !== undefined);
if (playlistIds.length > 0) {
const removeResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'remove_from_playlist',
media_ids: selectedMediaIds,
playlist_ids: playlistIds,
}),
});
if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove media from playlists'));
}
}
}
onSuccess(translateString('Successfully updated playlist membership'));
onCancel();
} catch (error) {
console.error('Error processing playlists:', error);
onError(translateString('Failed to update playlists. Please try again.'));
} finally {
setIsProcessing(false);
}
};
const filteredAvailablePlaylists = availablePlaylists.filter((playlist) =>
playlist.title.toLowerCase().includes(searchTerm.toLowerCase())
);
const hasChanges =
selectedPlaylists.length !== originalSelectedPlaylists.length ||
!selectedPlaylists.every((p) =>
originalSelectedPlaylists.some((op) => op.friendly_token === p.friendly_token)
);
if (!isOpen) return null;
return (
<div className="playlist-modal-overlay">
<div className="playlist-modal">
<div className="playlist-modal-header">
<h2>{translateString('Manage Playlists')}</h2>
<button className="playlist-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="playlist-modal-content">
<div className="playlist-panel">
<h3>{translateString('Playlists')}</h3>
{isCreatingPlaylist ? (
<div className="create-playlist-form">
<input
type="text"
placeholder={translateString('Enter playlist name...')}
value={newPlaylistName}
onChange={(e) => setNewPlaylistName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleCreatePlaylist();
}
}}
autoFocus
/>
<div className="create-playlist-buttons">
<button className="create-btn" onClick={handleCreatePlaylist}>
{translateString('Create')}
</button>
<button className="cancel-btn" onClick={() => setIsCreatingPlaylist(false)}>
{translateString('Cancel')}
</button>
</div>
</div>
) : (
<button className="create-playlist-btn" onClick={() => setIsCreatingPlaylist(true)}>
{translateString('+ Create Playlist')}
</button>
)}
<div className="search-box">
<input
type="text"
placeholder={translateString('Filter playlists...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{isLoading ? (
<div className="loading-message">{translateString('Loading playlists...')}</div>
) : (
<div className="playlist-list">
{filteredAvailablePlaylists.length === 0 ? (
<div className="empty-message">{translateString('No playlists available')}</div>
) : (
filteredAvailablePlaylists.map((playlist) => {
const isSelected = selectedPlaylists.some((p) => p.friendly_token === playlist.friendly_token);
return (
<div
key={playlist.friendly_token}
className={`playlist-item ${isSelected ? 'playlist-item-disabled' : ''}`}
onClick={() => !isSelected && handlePlaylistSelect(playlist)}
>
<span>{playlist.title}</span>
<button className="add-btn" disabled={isSelected}>
+
</button>
</div>
);
})
)}
</div>
)}
</div>
<div className="playlist-panel">
<h3>
{translateString('Add to')}
{selectedMediaIds.length > 1 && (
<span className="info-tooltip" title={translateString('The intersection of playlists in the selected media is shown')}>
?
</span>
)}
</h3>
<div className="playlist-list">
{selectedPlaylists.length === 0 ? (
<div className="empty-message">{translateString('No playlists selected')}</div>
) : (
selectedPlaylists.map((playlist) => (
<div key={playlist.friendly_token} className="playlist-item">
<span>{playlist.title}</span>
<button className="remove-btn" onClick={() => handlePlaylistRemove(playlist)}>
×
</button>
</div>
))
)}
</div>
</div>
</div>
<div className="playlist-modal-footer">
<button className="playlist-btn playlist-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="playlist-btn playlist-btn-proceed"
onClick={handleProceed}
disabled={isProcessing || !hasChanges}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,229 @@
@import '../../css/config/index.scss';
.publish-state-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.publish-state-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.publish-state-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.publish-state-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.publish-state-modal-content {
padding: 24px;
flex: 1;
overflow: visible;
display: flex;
flex-direction: column;
gap: 16px;
}
.state-selector {
display: flex;
flex-direction: column;
gap: 12px;
label {
font-size: 14px;
font-weight: 500;
color: #333;
.dark_theme & {
color: #fff;
}
}
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background-color: white;
cursor: pointer;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dark_theme & {
background-color: #333;
border-color: #555;
color: #fff;
&:focus {
border-color: var(--default-theme-color, #009933);
}
}
}
}
.publish-state-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.publish-state-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.publish-state-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.publish-state-btn-submit {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}

View File

@@ -0,0 +1,124 @@
import React, { useState, useEffect } from 'react';
import './BulkActionPublishStateModal.scss';
import { translateString } from '../utils/helpers/';
interface BulkActionPublishStateModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
const PUBLISH_STATES = [
{ value: 'public', label: translateString('Public') },
{ value: 'unlisted', label: translateString('Unlisted') },
{ value: 'private', label: translateString('Private') },
];
export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const [selectedState, setSelectedState] = useState('public');
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setSelectedState('public');
}
}, [isOpen]);
const handleSubmit = async () => {
if (!selectedState) {
onError(translateString('Please select a publish state'));
return;
}
setIsProcessing(true);
try {
const response = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'set_state',
media_ids: selectedMediaIds,
state: selectedState,
}),
});
if (!response.ok) {
throw new Error(translateString('Failed to set publish state'));
}
const data = await response.json();
onSuccess(data.detail || translateString('Successfully updated publish state'));
onCancel();
} catch (error) {
console.error('Error setting publish state:', error);
onError(translateString('Failed to set publish state. Please try again.'));
} finally {
setIsProcessing(false);
}
};
if (!isOpen) return null;
// Note: We don't check hasStateChanged because the modal doesn't know the actual
// current state of the selected media. Users should be able to set any state.
// If the state is already the same, the backend will handle it gracefully.
return (
<div className="publish-state-modal-overlay">
<div className="publish-state-modal">
<div className="publish-state-modal-header">
<h2>{translateString('Publish State')}</h2>
<button className="publish-state-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="publish-state-modal-content">
<div className="state-selector">
<label htmlFor="publish-state-select">{translateString('Select publish state:')}</label>
<select
id="publish-state-select"
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
disabled={isProcessing}
>
{PUBLISH_STATES.map((state) => (
<option key={state.value} value={state.value}>
{state.label}
</option>
))}
</select>
</div>
</div>
<div className="publish-state-modal-footer">
<button className="publish-state-btn publish-state-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit}
disabled={isProcessing}
>
{isProcessing ? translateString('Processing...') : translateString('Submit')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,433 @@
@import '../../css/config/index.scss';
.tag-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tag-modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
color: #fff;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.tag-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
.dark_theme & {
border-bottom-color: #444;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
.dark_theme & {
color: #fff;
}
}
}
.tag-modal-close {
background: none;
border: none;
font-size: 32px;
color: #666;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color 0.2s ease;
&:hover {
color: #333;
}
.dark_theme & {
color: #aaa;
&:hover {
color: #fff;
}
}
}
.tag-modal-content {
display: flex;
gap: 24px;
padding: 24px;
flex: 1;
overflow: visible;
}
.tag-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: visible;
h3 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
.dark_theme & {
color: #fff;
}
}
h4 {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
color: #666;
.dark_theme & {
color: #aaa;
}
}
}
.info-tooltip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #ccc;
color: #fff;
font-size: 12px;
font-weight: bold;
cursor: help;
transition: background-color 0.2s ease;
&:hover {
background-color: #999;
}
.dark_theme & {
background-color: #555;
&:hover {
background-color: #777;
}
}
}
.tag-modal {
.available-tags {
margin-top: 16px;
flex: 1;
display: flex;
flex-direction: column;
overflow: visible;
}
.tag-list {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
background-color: #f9f9f9;
min-height: 100px;
.dark_theme & {
background-color: #333;
border-color: #555;
}
&.scrollable {
max-height: 300px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
.dark_theme & {
background: #2a2a2a;
}
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
&:hover {
background: #aaa;
}
.dark_theme & {
background: #555;
&:hover {
background: #666;
}
}
}
}
}
.tag-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background-color: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s ease;
.dark_theme & {
background-color: #2a2a2a;
border-color: #444;
color: #fff;
}
&.clickable {
cursor: pointer;
&:hover {
background-color: #f0f7ff;
border-color: var(--default-theme-color, #009933);
.dark_theme & {
background-color: #3a3a3a;
}
}
}
&.marked-for-removal {
background-color: #ffe0e0;
border-color: #ffaaaa;
opacity: 0.7;
.dark_theme & {
background-color: #4a2a2a;
border-color: #aa5555;
}
span {
text-decoration: line-through;
}
}
span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.add-btn,
.remove-btn {
background: none;
border: none;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.add-btn {
color: var(--default-theme-color, #009933);
&:hover {
background-color: rgba(0, 153, 51, 0.1);
}
.dark_theme & {
color: #66bb66;
&:hover {
background-color: rgba(102, 187, 102, 0.2);
}
}
}
.remove-btn {
color: #dc3545;
&:hover {
background-color: rgba(220, 53, 69, 0.1);
color: #c82333;
}
.dark_theme & {
color: #ff6b6b;
&:hover {
background-color: rgba(255, 107, 107, 0.2);
color: #ff8787;
}
}
}
.empty-message,
.loading-message {
padding: 40px 20px;
text-align: center;
color: #999;
font-size: 14px;
font-style: italic;
.dark_theme & {
color: #666;
}
}
}
.tag-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e0e0e0;
.dark_theme & {
border-top-color: #444;
}
}
.tag-btn {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.15);
}
}
.tag-btn-cancel {
background-color: #e0e0e0;
color: #333;
&:hover:not(:disabled) {
background-color: #d0d0d0;
}
.dark_theme & {
background-color: #444;
color: #fff;
&:hover:not(:disabled) {
background-color: #555;
}
}
}
.tag-btn-proceed {
background-color: var(--default-theme-color, #009933);
color: white;
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
.dark_theme & {
background-color: var(--default-theme-color, #009933);
&:hover:not(:disabled) {
background-color: var(--default-theme-color, #009933);
opacity: 0.9;
}
}
}
// Responsive design for smaller screens
@media (max-width: 768px) {
.tag-modal {
max-width: 95%;
}
.tag-modal-content {
flex-direction: column;
gap: 16px;
}
.tag-list.scrollable {
max-height: 150px;
}
}

View File

@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import './BulkActionTagModal.scss';
import { translateString } from '../utils/helpers/';
interface Tag {
title: string;
}
interface BulkActionTagModalProps {
isOpen: boolean;
selectedMediaIds: string[];
onCancel: () => void;
onSuccess: (message: string) => void;
onError: (message: string) => void;
csrfToken: string;
}
export const BulkActionTagModal: React.FC<BulkActionTagModalProps> = ({
isOpen,
selectedMediaIds,
onCancel,
onSuccess,
onError,
csrfToken,
}) => {
const [existingTags, setExistingTags] = useState<Tag[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [tagsToAdd, setTagsToAdd] = useState<Tag[]>([]);
const [tagsToRemove, setTagsToRemove] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
if (isOpen && selectedMediaIds.length > 0) {
fetchData();
} else {
// Reset state when modal closes
setExistingTags([]);
setAllTags([]);
setTagsToAdd([]);
setTagsToRemove([]);
}
}, [isOpen, selectedMediaIds]);
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch existing tags (intersection - tags all selected media belong to)
const existingResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'tag_membership',
media_ids: selectedMediaIds,
}),
});
if (!existingResponse.ok) {
throw new Error(translateString('Failed to fetch existing tags'));
}
const existingData = await existingResponse.json();
const existing = existingData.results || [];
// Fetch all tags
const allResponse = await fetch('/api/v1/tags');
if (!allResponse.ok) {
throw new Error(translateString('Failed to fetch all tags'));
}
const allData = await allResponse.json();
const all = allData.results || allData;
setExistingTags(existing);
setAllTags(all);
} catch (error) {
console.error('Error fetching tags:', error);
onError(translateString('Failed to load tags'));
} finally {
setIsLoading(false);
}
};
const addTagToList = (tag: Tag) => {
if (!tagsToAdd.some((t) => t.title === tag.title)) {
setTagsToAdd([...tagsToAdd, tag]);
}
};
const removeTagFromAddList = (tag: Tag) => {
setTagsToAdd(tagsToAdd.filter((t) => t.title !== tag.title));
};
const markTagForRemoval = (tag: Tag) => {
if (!tagsToRemove.some((t) => t.title === tag.title)) {
setTagsToRemove([...tagsToRemove, tag]);
}
};
const unmarkTagForRemoval = (tag: Tag) => {
setTagsToRemove(tagsToRemove.filter((t) => t.title !== tag.title));
};
const handleProceed = async () => {
setIsProcessing(true);
try {
// First, add tags if any
if (tagsToAdd.length > 0) {
const titlesToAdd = tagsToAdd.map((t) => t.title);
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'add_tags',
media_ids: selectedMediaIds,
tag_titles: titlesToAdd,
}),
});
if (!addResponse.ok) {
throw new Error(translateString('Failed to add tags'));
}
}
// Then, remove tags if any
if (tagsToRemove.length > 0) {
const titlesToRemove = tagsToRemove.map((t) => t.title);
const removeResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
action: 'remove_tags',
media_ids: selectedMediaIds,
tag_titles: titlesToRemove,
}),
});
if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove tags'));
}
}
onSuccess(translateString('Successfully updated tags'));
onCancel();
} catch (error) {
console.error('Error processing tags:', error);
onError(translateString('Failed to update tags. Please try again.'));
} finally {
setIsProcessing(false);
}
};
// Get tags for left panel (all tags minus those already existing)
const getLeftPanelTags = () => {
return allTags.filter(
(tag) => !existingTags.some((existing) => existing.title === tag.title)
);
};
// Get tags for right panel ("Add to" - existing + newly added)
const getRightPanelTags = () => {
// Combine existing tags with newly added ones
const combined = [...existingTags, ...tagsToAdd];
return combined;
};
if (!isOpen) return null;
const leftPanelTags = getLeftPanelTags();
const rightPanelTags = getRightPanelTags();
return (
<div className="tag-modal-overlay">
<div className="tag-modal">
<div className="tag-modal-header">
<h2>{translateString('Add / Remove Tags')}</h2>
<button className="tag-modal-close" onClick={onCancel}>
×
</button>
</div>
<div className="tag-modal-content">
<div className="tag-panel">
<h3>{translateString('Tags')}</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading tags...')}</div>
) : (
<div className="tag-list scrollable">
{leftPanelTags.length === 0 ? (
<div className="empty-message">{translateString('All tags already added')}</div>
) : (
leftPanelTags.map((tag) => (
<div
key={tag.title}
className="tag-item clickable"
onClick={() => addTagToList(tag)}
>
<span>{tag.title}</span>
<button className="add-btn">+</button>
</div>
))
)}
</div>
)}
</div>
<div className="tag-panel">
<h3>
{translateString('Add to')}
{selectedMediaIds.length > 1 && (
<span className="info-tooltip" title={translateString('The intersection of tags in the selected media is shown')}>
?
</span>
)}
</h3>
{isLoading ? (
<div className="loading-message">{translateString('Loading tags...')}</div>
) : (
<div className="tag-list scrollable">
{rightPanelTags.length === 0 ? (
<div className="empty-message">{translateString('No tags')}</div>
) : (
rightPanelTags.map((tag) => {
const isExisting = existingTags.some((t) => t.title === tag.title);
const isMarkedForRemoval = tagsToRemove.some((t) => t.title === tag.title);
return (
<div key={tag.title} className={`tag-item ${isMarkedForRemoval ? 'marked-for-removal' : ''}`}>
<span>{tag.title}</span>
<button
className="remove-btn"
onClick={() => {
if (isExisting) {
// This is an existing tag - mark/unmark for removal
isMarkedForRemoval ? unmarkTagForRemoval(tag) : markTagForRemoval(tag);
} else {
// This is a newly added tag - remove from add list
removeTagFromAddList(tag);
}
}}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove tag') : translateString('Remove from list')}
>
{isMarkedForRemoval ? '↺' : '×'}
</button>
</div>
);
})
)}
</div>
)}
</div>
</div>
<div className="tag-modal-footer">
<button className="tag-btn tag-btn-cancel" onClick={onCancel} disabled={isProcessing}>
{translateString('Cancel')}
</button>
<button
className="tag-btn tag-btn-proceed"
onClick={handleProceed}
disabled={isProcessing || (tagsToAdd.length === 0 && tagsToRemove.length === 0)}
>
{isProcessing ? translateString('Processing...') : translateString('Proceed')}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
@import '../../css/config/index.scss';
.bulk-actions-dropdown {
display: inline-block;
margin-bottom: 16px;
.bulk-actions-select {
width: auto;
max-width: 220px;
height: 36px;
padding: 0 28px 0 10px;
font-size: 13px;
font-weight: 600;
color: #333;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
&:hover {
background-color: #e8e8e8;
border-color: #ccc;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
}
&:focus {
outline: none;
border-color: var(--default-theme-color, #009933);
box-shadow: 0 0 0 3px rgba(0, 153, 51, 0.25);
}
&.no-selection {
color: #666;
}
option {
padding: 10px;
font-weight: normal;
font-style: normal;
color: #333;
background-color: white;
&:disabled {
color: #999;
}
&:not(:disabled) {
color: #000;
}
}
}
.dark_theme & {
.bulk-actions-select {
color: #fff;
background-color: #3a3a3a;
border-color: #555;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
&:hover {
background-color: #454545;
border-color: #666;
}
&:focus {
border-color: var(--default-theme-color, #009933);
}
&.no-selection {
color: #aaa;
}
option {
background-color: #2a2a2a;
color: #fff;
&:disabled {
color: #777;
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import './BulkActionsDropdown.scss';
import { translateString } from '../utils/helpers/';
interface BulkActionsDropdownProps {
selectedCount: number;
onActionSelect: (action: string) => void;
}
const BULK_ACTIONS = [
{ value: 'add-remove-coviewers', label: translateString('Add / Remove Co-Viewers'), enabled: true },
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
{ value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true },
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
{ value: 'add-remove-category', label: translateString('Add to / Remove from Category'), enabled: true },
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },
{ value: 'enable-download', label: translateString('Enable Download'), enabled: true },
{ value: 'disable-download', label: translateString('Disable Download'), enabled: true },
{ value: 'publish-state', label: translateString('Publish State'), enabled: true },
{ value: 'change-owner', label: translateString('Change Owner'), enabled: true },
{ value: 'copy-media', label: translateString('Copy Media'), enabled: true },
{ value: 'delete-media', label: translateString('Delete Media'), enabled: true },
];
export const BulkActionsDropdown: React.FC<BulkActionsDropdownProps> = ({ selectedCount, onActionSelect }) => {
const noSelection = selectedCount === 0;
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value;
if (!value) return;
if (noSelection) {
event.target.value = '';
return;
}
onActionSelect(value);
// Reset dropdown after selection
event.target.value = '';
};
const displayText = noSelection
? translateString('Bulk Actions')
: `${translateString('Bulk Actions')} (${selectedCount} ${translateString('selected')})`;
return (
<div className="bulk-actions-dropdown">
<select
className={'bulk-actions-select' + (noSelection ? ' no-selection' : '')}
onChange={handleChange}
value=""
aria-label={translateString('Bulk Actions')}
>
<option value="" disabled>
{displayText}
</option>
{BULK_ACTIONS.map((action) => (
<option key={action.value} value={action.value} disabled={noSelection || !action.enabled}>
{action.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -0,0 +1,201 @@
import React from 'react';
import PropTypes from 'prop-types';
import { BulkActionConfirmModal } from './BulkActionConfirmModal';
import { BulkActionPermissionModal } from './BulkActionPermissionModal';
import { BulkActionPlaylistModal } from './BulkActionPlaylistModal';
import { BulkActionChangeOwnerModal } from './BulkActionChangeOwnerModal';
import { BulkActionPublishStateModal } from './BulkActionPublishStateModal';
import { BulkActionCategoryModal } from './BulkActionCategoryModal';
import { BulkActionTagModal } from './BulkActionTagModal';
/**
* Renders all bulk action modals
* This component is reusable across different pages
*/
export function BulkActionsModals({
// Confirm modal props
showConfirmModal,
confirmMessage,
onConfirmCancel,
onConfirmProceed,
// Permission modal props
showPermissionModal,
permissionType,
selectedMediaIds,
onPermissionModalCancel,
onPermissionModalSuccess,
onPermissionModalError,
// Playlist modal props
showPlaylistModal,
onPlaylistModalCancel,
onPlaylistModalSuccess,
onPlaylistModalError,
username,
// Change owner modal props
showChangeOwnerModal,
onChangeOwnerModalCancel,
onChangeOwnerModalSuccess,
onChangeOwnerModalError,
// Publish state modal props
showPublishStateModal,
onPublishStateModalCancel,
onPublishStateModalSuccess,
onPublishStateModalError,
// Category modal props
showCategoryModal,
onCategoryModalCancel,
onCategoryModalSuccess,
onCategoryModalError,
// Tag modal props
showTagModal,
onTagModalCancel,
onTagModalSuccess,
onTagModalError,
// Common props
csrfToken,
// Notification
showNotification,
notificationMessage,
notificationType,
}) {
return (
<>
<BulkActionConfirmModal
isOpen={showConfirmModal}
message={confirmMessage}
onCancel={onConfirmCancel}
onProceed={onConfirmProceed}
/>
<BulkActionPermissionModal
isOpen={showPermissionModal}
permissionType={permissionType}
selectedMediaIds={selectedMediaIds}
onCancel={onPermissionModalCancel}
onSuccess={onPermissionModalSuccess}
onError={onPermissionModalError}
csrfToken={csrfToken}
/>
<BulkActionPlaylistModal
isOpen={showPlaylistModal}
selectedMediaIds={selectedMediaIds}
onCancel={onPlaylistModalCancel}
onSuccess={onPlaylistModalSuccess}
onError={onPlaylistModalError}
csrfToken={csrfToken}
username={username}
/>
<BulkActionChangeOwnerModal
isOpen={showChangeOwnerModal}
selectedMediaIds={selectedMediaIds}
onCancel={onChangeOwnerModalCancel}
onSuccess={onChangeOwnerModalSuccess}
onError={onChangeOwnerModalError}
csrfToken={csrfToken}
/>
<BulkActionPublishStateModal
isOpen={showPublishStateModal}
selectedMediaIds={selectedMediaIds}
onCancel={onPublishStateModalCancel}
onSuccess={onPublishStateModalSuccess}
onError={onPublishStateModalError}
csrfToken={csrfToken}
/>
<BulkActionCategoryModal
isOpen={showCategoryModal}
selectedMediaIds={selectedMediaIds}
onCancel={onCategoryModalCancel}
onSuccess={onCategoryModalSuccess}
onError={onCategoryModalError}
csrfToken={csrfToken}
/>
<BulkActionTagModal
isOpen={showTagModal}
selectedMediaIds={selectedMediaIds}
onCancel={onTagModalCancel}
onSuccess={onTagModalSuccess}
onError={onTagModalError}
csrfToken={csrfToken}
/>
{showNotification && (
<div
style={{
position: 'fixed',
bottom: '20px',
left: '260px',
backgroundColor: notificationType === 'error' ? '#f44336' : '#4CAF50',
color: 'white',
padding: '16px 24px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
fontSize: '14px',
fontWeight: '500',
}}
>
{notificationMessage}
</div>
)}
</>
);
}
BulkActionsModals.propTypes = {
showConfirmModal: PropTypes.bool.isRequired,
confirmMessage: PropTypes.string.isRequired,
onConfirmCancel: PropTypes.func.isRequired,
onConfirmProceed: PropTypes.func.isRequired,
showPermissionModal: PropTypes.bool.isRequired,
permissionType: PropTypes.oneOf(['viewer', 'editor', 'owner', null]),
selectedMediaIds: PropTypes.array.isRequired,
onPermissionModalCancel: PropTypes.func.isRequired,
onPermissionModalSuccess: PropTypes.func.isRequired,
onPermissionModalError: PropTypes.func.isRequired,
showPlaylistModal: PropTypes.bool.isRequired,
onPlaylistModalCancel: PropTypes.func.isRequired,
onPlaylistModalSuccess: PropTypes.func.isRequired,
onPlaylistModalError: PropTypes.func.isRequired,
username: PropTypes.string,
showChangeOwnerModal: PropTypes.bool.isRequired,
onChangeOwnerModalCancel: PropTypes.func.isRequired,
onChangeOwnerModalSuccess: PropTypes.func.isRequired,
onChangeOwnerModalError: PropTypes.func.isRequired,
showPublishStateModal: PropTypes.bool.isRequired,
onPublishStateModalCancel: PropTypes.func.isRequired,
onPublishStateModalSuccess: PropTypes.func.isRequired,
onPublishStateModalError: PropTypes.func.isRequired,
showCategoryModal: PropTypes.bool.isRequired,
onCategoryModalCancel: PropTypes.func.isRequired,
onCategoryModalSuccess: PropTypes.func.isRequired,
onCategoryModalError: PropTypes.func.isRequired,
showTagModal: PropTypes.bool.isRequired,
onTagModalCancel: PropTypes.func.isRequired,
onTagModalSuccess: PropTypes.func.isRequired,
onTagModalError: PropTypes.func.isRequired,
csrfToken: PropTypes.string.isRequired,
showNotification: PropTypes.bool.isRequired,
notificationMessage: PropTypes.string.isRequired,
notificationType: PropTypes.oneOf(['success', 'error']).isRequired,
};

View File

@@ -6,6 +6,14 @@
margin: 0 auto;
width: 100%;
max-width: 100%;
.bulk-actions-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 16px;
}
}
.media-list-row {

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { MediaListRow } from './MediaListRow';
import { BulkActionsDropdown } from './BulkActionsDropdown';
import { SelectAllCheckbox } from './SelectAllCheckbox';
import './MediaListWrapper.scss';
interface MediaListWrapperProps {
@@ -9,6 +11,12 @@ interface MediaListWrapperProps {
className?: string;
style?: { [key: string]: any };
children?: any;
showBulkActions?: boolean;
selectedCount?: number;
totalCount?: number;
onBulkAction?: (action: string) => void;
onSelectAll?: () => void;
onDeselectAll?: () => void;
}
export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
@@ -18,9 +26,26 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
className,
style,
children,
showBulkActions = false,
selectedCount = 0,
totalCount = 0,
onBulkAction = () => {},
onSelectAll = () => {},
onDeselectAll = () => {},
}) => (
<div className={(className ? className + ' ' : '') + 'media-list-wrapper'} style={style}>
<MediaListRow title={title} viewAllLink={viewAllLink} viewAllText={viewAllText}>
{showBulkActions && (
<div className="bulk-actions-container">
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
<SelectAllCheckbox
totalCount={totalCount}
selectedCount={selectedCount}
onSelectAll={onSelectAll}
onDeselectAll={onDeselectAll}
/>
</div>
)}
{children || null}
</MediaListRow>
</div>

View File

@@ -0,0 +1,53 @@
@import '../../css/config/index.scss';
.select-all-checkbox {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
.select-all-label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
font-size: 15px;
font-weight: 600;
color: #333;
transition: color 0.2s ease;
&:hover:not(.disabled) {
color: var(--brand-color, #007bff);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type='checkbox'] {
width: 20px;
height: 20px;
margin: 0 8px 0 0;
cursor: pointer;
accent-color: var(--brand-color, #007bff);
&:disabled {
cursor: not-allowed;
}
}
.checkbox-label-text {
line-height: 1;
}
}
.dark_theme & {
.select-all-label {
color: #fff;
&:hover:not(.disabled) {
color: var(--brand-color, #4da3ff);
}
}
}
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import './SelectAllCheckbox.scss';
import { translateString } from '../utils/helpers/';
interface SelectAllCheckboxProps {
totalCount: number;
selectedCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
}
export const SelectAllCheckbox: React.FC<SelectAllCheckboxProps> = ({
totalCount,
selectedCount,
onSelectAll,
onDeselectAll,
}) => {
const allSelected = totalCount > 0 && selectedCount === totalCount;
const someSelected = selectedCount > 0 && selectedCount < totalCount;
const handleChange = () => {
if (allSelected || someSelected) {
onDeselectAll();
} else {
onSelectAll();
}
};
const isDisabled = totalCount === 0;
return (
<div className="select-all-checkbox">
<label className={'select-all-label' + (isDisabled ? ' disabled' : '')}>
<input
type="checkbox"
checked={allSelected}
ref={(input) => {
if (input) {
input.indeterminate = someSelected;
}
}}
onChange={handleChange}
disabled={isDisabled}
aria-label={translateString('Select all media')}
/>
<span className="checkbox-label-text">{translateString('All')}</span>
</label>
</div>
);
};

View File

@@ -38,7 +38,9 @@ function NotificationItem(props) {
return !isVisible ? null : (
<div className={'notification-item' + (isHidden ? ' hidden' : '')}>
<div>{props.children || null}</div>
<div>
<span>{props.children || 'No message'}</span>
</div>
</div>
);
}

View File

@@ -22,7 +22,7 @@
max-width: 100%;
min-height: 48px;
margin: 12px;
color: #f1f1f1;
color: #f1f1f1 !important;
background-color: #323232;
border-radius: 2px;
box-shadow: 0 2px 5px 0 rgba(#000, 0.26);
@@ -35,6 +35,12 @@
line-height: 20px;
padding: 8px 24px;
overflow: hidden;
color: #f1f1f1 !important;
font-size: 14px !important;
}
div {
color: #f1f1f1 !important;
}
&.hidden {

View File

@@ -31,7 +31,8 @@ export function LazyLoadItemListAsync(props) {
props.firstItemRequestUrl,
props.requestUrl,
onItemsCount,
onItemsLoad
onItemsLoad,
props.onResponseDataLoaded
)
);
@@ -51,6 +52,12 @@ export function LazyLoadItemListAsync(props) {
};
}, []);
useEffect(() => {
if (props.onItemsUpdate && items.length > 0) {
props.onItemsUpdate(items);
}
}, [items]);
return !countedItems ? (
<PendingItemsList className={classname.listOuter} />
) : !items.length ? null : (
@@ -60,7 +67,15 @@ export function LazyLoadItemListAsync(props) {
<div ref={itemsListWrapperRef} className="items-list-wrap">
<div ref={itemsListRef} className={classname.list}>
{items.map((itm, index) => (
<ListItem key={index} {...listItemProps(props, itm, index)} />
<ListItem
key={index}
{...listItemProps(props, itm, index)}
showSelection={props.showSelection}
hasAnySelection={props.hasAnySelection}
isSelected={props.selectedMedia && props.selectedMedia.has(itm.friendly_token || itm.uid || itm.id)}
onSelectionChange={props.onMediaSelection}
mediaId={itm.friendly_token || itm.uid || itm.id}
/>
))}
</div>
</div>

View File

@@ -7,7 +7,8 @@ export function ItemsListHandler(
first_item_request_url,
request_url,
itemsCountCallback,
loadItemsCallback
loadItemsCallback,
responseDataCallback
) {
const config = {
maxItems: maxItems || 255,
@@ -122,6 +123,11 @@ export function ItemsListHandler(
state.totalPages = Math.ceil(state.totalItems / config.pageItems);
callbacks.itemsCount();
// Call response data callback with full response data
if ('function' === typeof responseDataCallback) {
responseDataCallback(data);
}
}
loadNextItems();

View File

@@ -598,3 +598,209 @@ a.item-edit-link {
}
}
}
// Bulk selection checkbox styles
.item.with-selection {
.item-content {
position: relative;
}
// Selected state styling
&.selected {
.item-content {
background-color: rgba(0, 0, 0, 0.05);
border: 2px solid var(--default-theme-color, #009933);
border-radius: 8px;
padding: 4px;
.dark_theme & {
background-color: rgba(255, 255, 255, 0.08);
}
}
.item-thumb {
opacity: 0.9;
}
}
.item-selection-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 4px;
opacity: 0;
transition: opacity 0.2s ease;
input[type='checkbox'] {
width: 24px;
height: 24px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border: 2px solid #666;
border-radius: 3px;
background-color: white;
transition: all 0.2s ease;
position: relative;
pointer-events: auto;
margin: 0;
flex-shrink: 0;
&:hover {
border-color: #333;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
&:checked {
background-color: var(--default-theme-color, #009933);
border-color: var(--default-theme-color, #009933);
&::after {
content: '';
position: absolute;
left: 7px;
top: 3px;
width: 6px;
height: 12px;
border: solid white;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
}
}
// Show checkbox only on hover
&:hover .item-selection-checkbox {
opacity: 1;
}
// Always show checkbox when this item is selected
&.selected .item-selection-checkbox {
opacity: 1;
}
// Show all checkboxes when any item has a selection
&.has-any-selection .item-selection-checkbox {
opacity: 1;
}
// Add hover shadow when any selection is active
&.has-any-selection:not(.selected):hover {
.item-content {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
transition: box-shadow 0.2s ease;
}
.item-thumb {
border-radius: 10px;
}
}
}
// Edit icon styles
.item-edit-icon {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
text-decoration: none;
opacity: 0;
transition: all 0.2s ease;
pointer-events: auto;
.dark_theme & {
background-color: rgba(42, 42, 42, 0.95);
}
.material-icons {
font-size: 18px;
color: #333;
.dark_theme & {
color: #fff;
}
}
&:hover {
background-color: var(--default-theme-color, #009933);
.material-icons {
color: white;
}
}
}
.item.with-selection:hover .item-edit-icon {
opacity: 1;
}
// View icon styles (eye icon below edit icon)
.item-view-icon {
position: absolute;
top: 48px;
right: 8px;
z-index: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
text-decoration: none;
opacity: 0;
transition: all 0.2s ease;
pointer-events: auto;
.dark_theme & {
background-color: rgba(42, 42, 42, 0.95);
}
.material-icons {
font-size: 18px;
color: #333;
.dark_theme & {
color: #fff;
}
}
&:hover {
background-color: var(--default-theme-color, #009933);
.material-icons {
color: white;
}
}
}
.item.with-selection:hover .item-view-icon {
opacity: 1;
}

View File

@@ -78,6 +78,7 @@ export function listItemProps(props, item, index) {
const url = {
view: itemPageLink(props, item),
edit: props.canEdit ? item.url.replace('view?m=', 'edit?m=') : null,
publish: props.canEdit ? item.url.replace('view?m=', 'publish?m=') : null,
};
if (window.MediaCMS.site.devEnv && -1 < url.view.indexOf('view?')) {
@@ -237,6 +238,12 @@ export function listItemProps(props, item, index) {
export function ListItem(props) {
let isMediaItem = false;
const handleCheckboxChange = (event) => {
if (props.onSelectionChange && props.mediaId) {
props.onSelectionChange(props.mediaId, event.target.checked);
}
};
const args = {
order: props.order,
title: props.title,
@@ -246,6 +253,10 @@ export function ListItem(props) {
singleLinkContent: props.singleLinkContent,
hasMediaViewer: props.hasMediaViewer,
hasMediaViewerDescr: props.hasMediaViewerDescr,
showSelection: props.showSelection,
hasAnySelection: props.hasAnySelection,
isSelected: props.isSelected,
onCheckboxChange: handleCheckboxChange,
};
switch (props.type) {
@@ -311,6 +322,7 @@ export function ListItem(props) {
if (props.canEdit) {
args.editLink = props.url.edit;
args.publishLink = props.url.publish;
}
if (props.taxonomyPage.current) {

View File

@@ -8,9 +8,10 @@ import { Item } from './Item';
export function MediaItem(props) {
const type = props.type;
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents] =
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
function thumbnailComponent() {
return <MediaItemThumbnailLink src={thumbnailUrl} title={props.title} link={props.link} />;
}
@@ -21,10 +22,45 @@ export function MediaItem(props) {
props.playlistOrder === props.playlistActiveItem
);
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
e.target.closest('.item-view-icon')) {
return; // Let these elements handle their own clicks
}
// Prevent all other clicks and toggle selection
e.preventDefault();
e.stopPropagation();
props.onCheckboxChange({ target: { checked: !props.isSelected } });
}
};
return (
<div className={containerClassname}>
<div className={finalClassname} onClick={handleItemClick}>
<div className="item-content">
{props.showSelection && (
<div className="item-selection-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={props.isSelected || false}
onChange={(e) => { props.onCheckboxChange && props.onCheckboxChange(e); }}
onClick={(e) => e.stopPropagation()}
aria-label="Select media"
/>
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{thumbnailComponent()}

View File

@@ -10,7 +10,7 @@ import { MediaItem } from './MediaItem';
export function MediaItemAudio(props) {
const type = props.type;
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents] =
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
const _MediaDurationInfo = new MediaDurationInfo();
@@ -65,12 +65,47 @@ export function MediaItemAudio(props) {
props.playlistOrder === props.playlistActiveItem
);
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
e.target.closest('.item-view-icon')) {
return; // Let these elements handle their own clicks
}
// Prevent all other clicks and toggle selection
e.preventDefault();
e.stopPropagation();
props.onCheckboxChange({ target: { checked: !props.isSelected } });
}
};
return (
<div className={containerClassname}>
<div className={finalClassname} onClick={handleItemClick}>
{playlistOrderNumberComponent()}
<div className="item-content">
{props.showSelection && (
<div className="item-selection-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={props.isSelected || false}
onChange={(e) => { props.onCheckboxChange && props.onCheckboxChange(e); }}
onClick={(e) => e.stopPropagation()}
aria-label="Select media"
/>
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{thumbnailComponent()}

View File

@@ -10,7 +10,7 @@ import { MediaItem } from './MediaItem';
export function MediaItemVideo(props) {
const type = props.type;
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents] =
const [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent] =
useMediaItem({ ...props, type });
const _MediaDurationInfo = new MediaDurationInfo();
@@ -72,12 +72,47 @@ export function MediaItemVideo(props) {
props.playlistOrder === props.playlistActiveItem
);
const finalClassname = containerClassname +
(props.showSelection ? ' with-selection' : '') +
(props.isSelected ? ' selected' : '') +
(props.hasAnySelection ? ' has-any-selection' : '');
const handleItemClick = (e) => {
// If there's any selection active, clicking the item should toggle selection
if (props.hasAnySelection && props.onCheckboxChange) {
// Check if clicking on the checkbox itself, edit icon, or view icon
if (e.target.closest('.item-selection-checkbox') ||
e.target.closest('.item-edit-icon') ||
e.target.closest('.item-view-icon')) {
return; // Let these elements handle their own clicks
}
// Prevent all other clicks and toggle selection
e.preventDefault();
e.stopPropagation();
props.onCheckboxChange({ target: { checked: !props.isSelected } });
}
};
return (
<div className={containerClassname}>
<div className={finalClassname} onClick={handleItemClick}>
{playlistOrderNumberComponent()}
<div className="item-content">
{props.showSelection && (
<div className="item-selection-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={props.isSelected || false}
onChange={(e) => { props.onCheckboxChange && props.onCheckboxChange(e); }}
onClick={(e) => e.stopPropagation()}
aria-label="Select media"
/>
</div>
)}
{editMediaComponent()}
{viewMediaComponent()}
{props.hasMediaViewer ? videoViewerComponent() : thumbnailComponent()}

View File

@@ -49,6 +49,11 @@ export function UserItemMemberSince(props) {
}
export function TaxonomyItemMediaCount(props) {
// Check if listing numbers should be included based on settings
if (!window.MediaCMS.features.listings.includeNumbers) {
return null;
}
return (
<span key="item-media-count" className="item-media-count">
{' ' + props.count} media
@@ -73,11 +78,19 @@ export function MediaItemEditLink(props) {
link = '/edit-media.html';
}
return !link ? null : (
<a href={link} title={translateString('Edit media')} className="item-edit-link">
{translateString('EDIT MEDIA')}
</a>
);
return !link ? null : (
<a href={link} title={translateString("Edit media")} className="item-edit-icon">
<i className="material-icons">edit</i>
</a>
);
}
export function MediaItemViewLink(props) {
return !props.link ? null : (
<a href={props.link} title={translateString("Publish media")} className="item-view-icon">
<i className="material-icons">publish</i>
</a>
);
}
export function MediaItemThumbnailLink(props) {

View File

@@ -66,7 +66,7 @@
}
@media (min-width: 1024px) {
width: 10%;
width: 20%;
&:nth-child(3n + 1),
&:nth-child(3n + 2),
@@ -98,6 +98,12 @@
padding-right: 0;
}
}
&.mi-filter-full-width {
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
}
.mi-filter-title {
@@ -150,9 +156,48 @@
&.active button,
button:hover {
color: inherit;
color: var(--default-theme-color);
opacity: 1;
}
&.active button {
font-weight: 600;
}
}
&.mi-filter-options-horizontal {
display: flex;
flex-wrap: wrap;
gap: 8px;
> * {
display: inline-block;
margin-top: 0;
margin-right: 8px;
&:last-child {
margin-right: 0;
}
button {
padding: 6px 12px;
border-radius: 4px;
background-color: var(--sidebar-nav-border-color);
opacity: 1;
&:hover {
background-color: var(--default-theme-color);
color: white;
opacity: 0.9;
}
}
&.active button {
background-color: var(--default-theme-color);
color: white;
font-weight: 600;
}
}
}
}
}

View File

@@ -728,7 +728,9 @@
.media-author-actions {
position: relative;
display: block;
display: flex;
align-items: center;
gap: 12px;
font-family: inherit;
margin-bottom: -8px;
@@ -738,28 +740,66 @@
}
}
> a,
> button {
position: relative;
width: auto;
padding: 8px 16px;
margin: 0.5rem 0;
font-size: 13px;
font-weight: 500;
font-weight: 400;
font-family: inherit;
line-height: 15px;
.edit-media-icon,
.remove-media-icon {
text-decoration: none;
color: #fff;
border: 0;
border-radius: 1px;
line-height: 1;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
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);
display: inline-block;
margin-bottom: 8px;
margin-right: 0.75rem;
.material-icons {
font-size: 20px;
line-height: 1;
}
&:last-child {
margin-right: 0;
&:active {
transform: scale(0.98);
}
}
.edit-media-icon {
background-color: rgba(0, 153, 51, 0.9);
&:hover {
background-color: rgba(0, 153, 51, 1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transform: scale(1.05);
}
.dark_theme & {
background-color: rgba(102, 187, 102, 0.9);
&:hover {
background-color: rgba(102, 187, 102, 1);
}
}
}
.remove-media-icon {
background-color: rgba(220, 53, 69, 0.9);
&:hover {
background-color: rgba(220, 53, 69, 1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
transform: scale(1.05);
}
.dark_theme & {
background-color: rgba(255, 107, 107, 0.9);
&:hover {
background-color: rgba(255, 107, 107, 1);
}
}
}

View File

@@ -80,8 +80,8 @@ function EditMediaButton(props) {
}
return (
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media">
{translateString('EDIT MEDIA')}
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
<i className="material-icons">edit</i>
</a>
);
}
@@ -217,31 +217,37 @@ export default function ViewerInfoContent(props) {
/>
) : null}
{userCan.editMedia || userCan.deleteMedia ? (
{userCan.editMedia ? (
<div className="media-author-actions">
{userCan.editMedia ? <EditMediaButton link={MediaPageStore.get('media-data').edit_url} /> : null}
<PopupTrigger contentRef={popupContentRef}>
<button className="remove-media">{translateString('DELETE MEDIA')}</button>
</PopupTrigger>
{userCan.deleteMedia ? (
<PopupTrigger contentRef={popupContentRef}>
<button className="remove-media-icon" title={translateString('Delete media')}>
<i className="material-icons">delete</i>
</button>
</PopupTrigger>
) : null}
<PopupContent contentRef={popupContentRef}>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Media removal</span>
<span className="popup-message-main">You're willing to remove media permanently?</span>
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}>
CANCEL
</button>
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
PROCEED
</button>
</span>
</PopupMain>
</PopupContent>
{userCan.deleteMedia ? (
<PopupContent contentRef={popupContentRef}>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Media removal</span>
<span className="popup-message-main">You're willing to remove media permanently?</span>
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}>
CANCEL
</button>
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
PROCEED
</button>
</span>
</PopupMain>
</PopupContent>
) : null}
</div>
) : null}
</div>

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 = {

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { FilterOptions } from '../_shared';
import { translateString } from '../../utils/helpers/';
import '../management-table/ManageItemList-filters.scss';
const filters = {
media_type: [
{ id: 'all', title: translateString('All') },
{ id: 'video', title: translateString('Video') },
{ id: 'audio', title: translateString('Audio') },
{ id: 'image', title: translateString('Image') },
{ id: 'pdf', title: translateString('Pdf') },
],
upload_date: [
{ id: 'all', title: translateString('All') },
{ id: 'today', title: translateString('Today') },
{ id: 'this_week', title: translateString('This week') },
{ id: 'this_month', title: translateString('This month') },
{ id: 'this_year', title: translateString('This year') },
],
duration: [
{ id: 'all', title: translateString('All') },
{ id: '0-20', title: translateString('00 - 20 min') },
{ id: '20-40', title: translateString('20 - 40 min') },
{ id: '40-60', title: translateString('40 - 60 min') },
{ id: '60-120', title: translateString('60 - 120 min+') },
],
publish_state: [
{ id: 'all', title: translateString('All') },
{ id: 'private', title: translateString('Private') },
{ id: 'unlisted', title: translateString('Unlisted') },
{ id: 'public', title: translateString('Published') },
],
sort_by: [
{ id: 'date_added_desc', title: translateString('Upload date (newest)') },
{ id: 'date_added_asc', title: translateString('Upload date (oldest)') },
{ id: 'most_views', title: translateString('View count') },
{ id: 'most_likes', title: translateString('Like count') },
],
};
export function ProfileMediaFilters(props) {
const [isHidden, setIsHidden] = useState(props.hidden);
const [mediaTypeFilter, setFilter_media_type] = useState('all');
const [uploadDateFilter, setFilter_upload_date] = useState('all');
const [durationFilter, setFilter_duration] = useState('all');
const [publishStateFilter, setFilter_publish_state] = useState('all');
const [sortByFilter, setFilter_sort_by] = useState('date_added_desc');
const [tagFilter, setFilter_tag] = useState('all');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
// Build tags filter options from props
const tagsOptions = [
{ id: 'all', title: 'All' },
...(props.tags || []).map((tag) => ({ id: tag, title: tag })),
];
function onWindowResize() {
if (!isHidden) {
containerRef.current.style.height = 24 + innerContainerRef.current.offsetHeight + 'px';
}
}
function onFilterSelect(ev) {
const filterType = ev.currentTarget.getAttribute('filter');
const clickedValue = ev.currentTarget.getAttribute('value');
const args = {
media_type: mediaTypeFilter,
upload_date: uploadDateFilter,
duration: durationFilter,
publish_state: publishStateFilter,
sort_by: props.selectedSort || sortByFilter,
tag: props.selectedTag || tagFilter,
};
switch (filterType) {
case 'media_type':
// If clicking the currently selected filter, deselect it (set to 'all')
args.media_type = clickedValue === mediaTypeFilter ? 'all' : clickedValue;
props.onFiltersUpdate(args);
setFilter_media_type(args.media_type);
break;
case 'upload_date':
args.upload_date = clickedValue === uploadDateFilter ? 'all' : clickedValue;
props.onFiltersUpdate(args);
setFilter_upload_date(args.upload_date);
break;
case 'duration':
args.duration = clickedValue === durationFilter ? 'all' : clickedValue;
props.onFiltersUpdate(args);
setFilter_duration(args.duration);
break;
case 'publish_state':
args.publish_state = clickedValue === publishStateFilter ? 'all' : clickedValue;
props.onFiltersUpdate(args);
setFilter_publish_state(args.publish_state);
break;
case 'sort_by':
args.sort_by = clickedValue === sortByFilter ? 'date_added_desc' : clickedValue;
props.onFiltersUpdate(args);
setFilter_sort_by(args.sort_by);
break;
case 'tag':
args.tag = clickedValue === tagFilter ? 'all' : clickedValue;
props.onFiltersUpdate(args);
setFilter_tag(args.tag);
break;
}
}
useEffect(() => {
setIsHidden(props.hidden);
onWindowResize();
}, [props.hidden]);
useEffect(() => {
PageStore.on('window_resize', onWindowResize);
return () => PageStore.removeListener('window_resize', onWindowResize);
}, []);
return (
<div ref={containerRef} className={'mi-filters-row' + (isHidden ? ' hidden' : '')}>
<div ref={innerContainerRef} className="mi-filters-row-inner">
<div className="mi-filter">
<div className="mi-filter-title">{translateString('MEDIA TYPE')}</div>
<div className="mi-filter-options">
<FilterOptions
id={'media_type'}
options={filters.media_type}
selected={mediaTypeFilter}
onSelect={onFilterSelect}
/>
</div>
</div>
<div className="mi-filter">
<div className="mi-filter-title">{translateString('UPLOAD DATE')}</div>
<div className="mi-filter-options">
<FilterOptions
id={'upload_date'}
options={filters.upload_date}
selected={uploadDateFilter}
onSelect={onFilterSelect}
/>
</div>
</div>
<div className="mi-filter">
<div className="mi-filter-title">{translateString('DURATION')}</div>
<div className="mi-filter-options">
<FilterOptions
id={'duration'}
options={filters.duration}
selected={durationFilter}
onSelect={onFilterSelect}
/>
</div>
</div>
<div className="mi-filter">
<div className="mi-filter-title">{translateString('PUBLISH STATE')}</div>
<div className="mi-filter-options">
<FilterOptions
id={'publish_state'}
options={filters.publish_state}
selected={publishStateFilter}
onSelect={onFilterSelect}
/>
</div>
</div>
</div>
</div>
);
}
ProfileMediaFilters.propTypes = {
hidden: PropTypes.bool,
tags: PropTypes.array,
onFiltersUpdate: PropTypes.func.isRequired,
selectedTag: PropTypes.string,
selectedSort: PropTypes.string,
};
ProfileMediaFilters.defaultProps = {
hidden: false,
tags: [],
};

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { FilterOptions } from '../_shared';
import { translateString } from '../../utils/helpers/';
import '../management-table/ManageItemList-filters.scss';
const sortOptions = {
sort_by: [
{ id: 'date_added_desc', title: translateString('Upload date - Newest') },
{ id: 'date_added_asc', title: translateString('Upload date - Oldest') },
{ id: 'alphabetically_asc', title: translateString('Alphabetically - A-Z') },
{ id: 'alphabetically_desc', title: translateString('Alphabetically - Z-A') },
{ id: 'plays_least', title: translateString('Plays - Least') },
{ id: 'plays_most', title: translateString('Plays - Most') },
{ id: 'likes_least', title: translateString('Likes - Least') },
{ id: 'likes_most', title: translateString('Likes - Most') },
],
};
export function ProfileMediaSorting(props) {
const [isHidden, setIsHidden] = useState(props.hidden);
const [sortByFilter, setFilter_sort_by] = useState('date_added_desc');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
function onWindowResize() {
if (!isHidden) {
containerRef.current.style.height = 24 + innerContainerRef.current.offsetHeight + 'px';
}
}
function onFilterSelect(ev) {
const sortBy = ev.currentTarget.getAttribute('value');
setFilter_sort_by(sortBy);
props.onSortSelect(sortBy);
}
useEffect(() => {
setIsHidden(props.hidden);
onWindowResize();
}, [props.hidden]);
useEffect(() => {
PageStore.on('window_resize', onWindowResize);
return () => PageStore.removeListener('window_resize', onWindowResize);
}, []);
return (
<div ref={containerRef} className={'mi-filters-row' + (isHidden ? ' hidden' : '')}>
<div ref={innerContainerRef} className="mi-filters-row-inner">
<div className="mi-filter">
<div className="mi-filter-title">{translateString('SORT BY')}</div>
<div className="mi-filter-options">
<FilterOptions id={'sort_by'} options={sortOptions.sort_by} selected={sortByFilter} onSelect={onFilterSelect} />
</div>
</div>
</div>
</div>
);
}
ProfileMediaSorting.propTypes = {
hidden: PropTypes.bool,
onSortSelect: PropTypes.func.isRequired,
};
ProfileMediaSorting.defaultProps = {
hidden: false,
};

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { FilterOptions } from '../_shared';
import { translateString } from '../../utils/helpers/';
import '../management-table/ManageItemList-filters.scss';
export function ProfileMediaTags(props) {
const [isHidden, setIsHidden] = useState(props.hidden);
const [tagFilter, setFilter_tag] = useState('all');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
// Build tags filter options from props
const tagsOptions = [
{ id: 'all', title: translateString('All') },
...(props.tags || []).map((tag) => ({ id: tag, title: tag })),
];
function onWindowResize() {
if (!isHidden) {
containerRef.current.style.height = 24 + innerContainerRef.current.offsetHeight + 'px';
}
}
function onFilterSelect(ev) {
const tag = ev.currentTarget.getAttribute('value');
// If clicking the currently selected tag, deselect it (set to 'all')
const newTag = tag === tagFilter ? 'all' : tag;
setFilter_tag(newTag);
props.onTagSelect(newTag);
}
useEffect(() => {
setIsHidden(props.hidden);
onWindowResize();
}, [props.hidden]);
useEffect(() => {
PageStore.on('window_resize', onWindowResize);
return () => PageStore.removeListener('window_resize', onWindowResize);
}, []);
return (
<div ref={containerRef} className={'mi-filters-row' + (isHidden ? ' hidden' : '')}>
<div ref={innerContainerRef} className="mi-filters-row-inner">
<div className="mi-filter mi-filter-full-width">
<div className="mi-filter-title">{translateString('TAGS')}</div>
<div className="mi-filter-options mi-filter-options-horizontal">
<FilterOptions id={'tag'} options={tagsOptions} selected={tagFilter} onSelect={onFilterSelect} />
</div>
</div>
</div>
</div>
);
}
ProfileMediaTags.propTypes = {
hidden: PropTypes.bool,
tags: PropTypes.array,
onTagSelect: PropTypes.func.isRequired,
};
ProfileMediaTags.defaultProps = {
hidden: false,
tags: [],
};

View File

@@ -2,34 +2,27 @@ import React from 'react';
import PropTypes from 'prop-types';
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
import { PageStore, ProfilePageStore } from '../utils/stores';
import { ProfilePageActions } from '../utils/actions';
import { ProfilePageActions, PageActions } from '../utils/actions';
import { translateString } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { BulkActionConfirmModal } from '../components/BulkActionConfirmModal';
import { BulkActionPermissionModal } from '../components/BulkActionPermissionModal';
import { BulkActionPlaylistModal } from '../components/BulkActionPlaylistModal';
import { BulkActionChangeOwnerModal } from '../components/BulkActionChangeOwnerModal';
import { BulkActionPublishStateModal } from '../components/BulkActionPublishStateModal';
import { BulkActionCategoryModal } from '../components/BulkActionCategoryModal';
import { BulkActionTagModal } from '../components/BulkActionTagModal';
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss';
function EmptyChannelMedia(props) {
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">Welcome {props.name}</div>
<div className="start-uploading">
Start uploading media and sharing your work. Media that you upload will show up here.
</div>
<a href={links.user.addMedia} title="Upload media" className="button-link">
<i className="material-icons" data-icon="video_call"></i>UPLOAD MEDIA
</a>
</div>
)}
</LinksConsumer>
);
}
export class ProfileMediaPage extends Page {
constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-home');
@@ -43,12 +36,75 @@ export class ProfileMediaPage extends Page {
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
selectedMedia: new Set(),
availableMediaIds: [],
showConfirmModal: false,
pendingAction: null,
confirmMessage: '',
listKey: 0,
notificationMessage: '',
showNotification: false,
notificationType: 'success',
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
showPermissionModal: false,
permissionType: null,
showPlaylistModal: false,
showChangeOwnerModal: false,
showPublishStateModal: false,
showCategoryModal: false,
showTagModal: false,
};
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.handleMediaSelection = this.handleMediaSelection.bind(this);
this.handleBulkAction = this.handleBulkAction.bind(this);
this.handleConfirmCancel = this.handleConfirmCancel.bind(this);
this.handleConfirmProceed = this.handleConfirmProceed.bind(this);
this.clearSelectionAndRefresh = this.clearSelectionAndRefresh.bind(this);
this.clearSelection = this.clearSelection.bind(this);
this.executeEnableComments = this.executeEnableComments.bind(this);
this.executeDisableComments = this.executeDisableComments.bind(this);
this.executeEnableDownload = this.executeEnableDownload.bind(this);
this.executeDisableDownload = this.executeDisableDownload.bind(this);
this.executeCopyMedia = this.executeCopyMedia.bind(this);
this.showNotification = this.showNotification.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleDeselectAll = this.handleDeselectAll.bind(this);
this.handleItemsUpdate = this.handleItemsUpdate.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
this.handlePermissionModalCancel = this.handlePermissionModalCancel.bind(this);
this.handlePermissionModalSuccess = this.handlePermissionModalSuccess.bind(this);
this.handlePermissionModalError = this.handlePermissionModalError.bind(this);
this.handlePlaylistModalCancel = this.handlePlaylistModalCancel.bind(this);
this.handlePlaylistModalSuccess = this.handlePlaylistModalSuccess.bind(this);
this.handlePlaylistModalError = this.handlePlaylistModalError.bind(this);
this.handleChangeOwnerModalCancel = this.handleChangeOwnerModalCancel.bind(this);
this.handleChangeOwnerModalSuccess = this.handleChangeOwnerModalSuccess.bind(this);
this.handleChangeOwnerModalError = this.handleChangeOwnerModalError.bind(this);
this.handlePublishStateModalCancel = this.handlePublishStateModalCancel.bind(this);
this.handlePublishStateModalSuccess = this.handlePublishStateModalSuccess.bind(this);
this.handlePublishStateModalError = this.handlePublishStateModalError.bind(this);
this.handleCategoryModalCancel = this.handleCategoryModalCancel.bind(this);
this.handleCategoryModalSuccess = this.handleCategoryModalSuccess.bind(this);
this.handleCategoryModalError = this.handleCategoryModalError.bind(this);
this.handleTagModalCancel = this.handleTagModalCancel.bind(this);
this.handleTagModalSuccess = this.handleTagModalSuccess.bind(this);
this.handleTagModalError = this.handleTagModalError.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
@@ -64,9 +120,9 @@ export class ProfileMediaPage extends Page {
if (author) {
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.search.query + this.state.query + '&author=' + author.id;
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id;
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + this.state.filterArgs;
}
}
@@ -92,11 +148,11 @@ export class ProfileMediaPage extends Page {
let title = '';
if (!count) {
title = 'No results for "' + this.state.query + '"';
title = translateString('No results for') + ' "' + this.state.query + '"';
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
title = translateString('1 result for') + ' "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
title = count + ' ' + translateString('results for') + ' "' + this.state.query + '"';
}
this.setState({
@@ -115,9 +171,9 @@ export class ProfileMediaPage extends Page {
let requestUrl;
if (newQuery) {
requestUrl = ApiUrlContext._currentValue.search.query + newQuery + '&author=' + this.state.author.id;
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id;
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs;
}
let title = this.state.title;
@@ -133,40 +189,820 @@ export class ProfileMediaPage extends Page {
});
}
handleMediaSelection(mediaId, isSelected) {
this.setState((prevState) => {
const newSelectedMedia = new Set(prevState.selectedMedia);
if (isSelected) {
newSelectedMedia.add(mediaId);
} else {
newSelectedMedia.delete(mediaId);
}
return { selectedMedia: newSelectedMedia };
});
}
handleBulkAction(action) {
const selectedCount = this.state.selectedMedia.size;
if (selectedCount === 0) {
return;
}
if (action === 'delete-media') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to delete') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'enable-comments') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to enable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'disable-comments') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to disable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'enable-download') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to enable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'disable-download') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to disable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'copy-media') {
this.setState({
showConfirmModal: true,
pendingAction: action,
confirmMessage: translateString('You are going to copy') + ` ${selectedCount} ` + translateString('media, are you sure?'),
});
} else if (action === 'add-remove-coviewers') {
this.setState({
showPermissionModal: true,
permissionType: 'viewer',
});
} else if (action === 'add-remove-coeditors') {
this.setState({
showPermissionModal: true,
permissionType: 'editor',
});
} else if (action === 'add-remove-coowners') {
this.setState({
showPermissionModal: true,
permissionType: 'owner',
});
} else if (action === 'add-remove-playlist') {
this.setState({
showPlaylistModal: true,
});
} else if (action === 'change-owner') {
this.setState({
showChangeOwnerModal: true,
});
} else if (action === 'publish-state') {
this.setState({
showPublishStateModal: true,
});
} else if (action === 'add-remove-category') {
this.setState({
showCategoryModal: true,
});
} else if (action === 'add-remove-tags') {
this.setState({
showTagModal: true,
});
} else {
// Other actions can be implemented later
}
}
handleConfirmCancel() {
this.setState({
showConfirmModal: false,
pendingAction: null,
confirmMessage: '',
});
}
handleConfirmProceed() {
const action = this.state.pendingAction;
this.setState({
showConfirmModal: false,
pendingAction: null,
confirmMessage: '',
});
if (action === 'delete-media') {
this.executeDeleteMedia();
} else if (action === 'enable-comments') {
this.executeEnableComments();
} else if (action === 'disable-comments') {
this.executeDisableComments();
} else if (action === 'enable-download') {
this.executeEnableDownload();
} else if (action === 'disable-download') {
this.executeDisableDownload();
} else if (action === 'copy-media') {
this.executeCopyMedia();
}
}
executeDeleteMedia() {
const selectedIds = Array.from(this.state.selectedMedia);
const selectedCount = selectedIds.length;
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'delete_media',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete media');
}
return response.json();
})
.then((data) => {
const message = selectedCount === 1
? translateString('The media was deleted successfully.')
: translateString('Successfully deleted') + ` ${selectedCount} ` + translateString('media.');
this.showNotification(message);
this.clearSelectionAndRefresh();
})
.catch((error) => {
this.showNotification(translateString('Failed to delete media. Please try again.'), 'error');
this.clearSelectionAndRefresh();
});
}
getCsrfToken() {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === name + '=') {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
clearSelectionAndRefresh() {
// Clear selected media and increment listKey to force re-render
this.setState((prevState) => ({
selectedMedia: new Set(),
listKey: prevState.listKey + 1,
}));
}
clearSelection() {
// Clear selected media without refreshing
this.setState({
selectedMedia: new Set(),
});
}
executeEnableComments() {
const selectedIds = Array.from(this.state.selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'enable_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to enable comments');
}
return response.json();
})
.then((data) => {
this.showNotification(translateString('Successfully Enabled comments'));
this.clearSelection();
})
.catch((error) => {
this.showNotification(translateString('Failed to enable comments.'), 'error');
this.clearSelection();
});
}
executeDisableComments() {
const selectedIds = Array.from(this.state.selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'disable_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to disable comments');
}
return response.json();
})
.then((data) => {
this.showNotification(translateString('Successfully Disabled comments'));
this.clearSelection();
})
.catch((error) => {
this.showNotification(translateString('Failed to disable comments.'), 'error');
this.clearSelection();
});
}
executeEnableDownload() {
const selectedIds = Array.from(this.state.selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'enable_download',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to enable download');
}
return response.json();
})
.then((data) => {
this.showNotification(translateString('Successfully Enabled Download'));
this.clearSelection();
})
.catch((error) => {
this.showNotification(translateString('Failed to enable download.'), 'error');
this.clearSelection();
});
}
executeDisableDownload() {
const selectedIds = Array.from(this.state.selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'disable_download',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to disable download');
}
return response.json();
})
.then((data) => {
this.showNotification(translateString('Successfully Disabled Download'));
this.clearSelection();
})
.catch((error) => {
this.showNotification(translateString('Failed to disable download.'), 'error');
this.clearSelection();
});
}
executeCopyMedia() {
const selectedIds = Array.from(this.state.selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken(),
},
body: JSON.stringify({
action: 'copy_media',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to copy media');
}
return response.json();
})
.then((data) => {
this.showNotification(translateString('Successfully Copied'));
this.clearSelectionAndRefresh();
})
.catch((error) => {
this.showNotification(translateString('Failed to copy media.'), 'error');
this.clearSelection();
});
}
showNotification(message, type = 'success') {
this.setState({
notificationMessage: message,
showNotification: true,
notificationType: type,
});
setTimeout(() => {
this.setState({ showNotification: false });
}, 5000);
}
handleItemsUpdate(items) {
// Extract media IDs from loaded items
const mediaIds = items.map((item) => item.friendly_token || item.uid || item.id);
this.setState({ availableMediaIds: mediaIds });
}
handleSelectAll() {
// Select all available media
this.setState({
selectedMedia: new Set(this.state.availableMediaIds),
});
}
handleDeselectAll() {
// Clear all selections
this.setState({
selectedMedia: new Set(),
});
}
onToggleFiltersClick() {
this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
onTagSelect(tag) {
this.setState({ selectedTag: tag }, () => {
// Apply tag filter
this.onFiltersUpdate({
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null,
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null,
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null,
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null,
sort_by: this.state.selectedSort,
tag: tag,
});
});
}
onSortSelect(sortOption) {
this.setState({ selectedSort: sortOption }, () => {
// Apply sort filter
this.onFiltersUpdate({
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null,
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null,
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null,
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null,
sort_by: sortOption,
tag: this.state.selectedTag,
});
});
}
onFiltersUpdate(updatedArgs) {
const args = {
media_type: null,
upload_date: null,
duration: null,
publish_state: null,
sort_by: null,
ordering: null,
t: null,
};
switch (updatedArgs.media_type) {
case 'video':
case 'audio':
case 'image':
case 'pdf':
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
// Handle tag filter
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
selectedMedia: new Set(), // Clear selected items when filter changes
},
function () {
// Update the request URL with new filter args
if (!this.state.author) {
return;
}
let requestUrl;
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs;
}
this.setState({
requestUrl: requestUrl,
});
}
);
}
handlePermissionModalCancel() {
this.setState({
showPermissionModal: false,
permissionType: null,
});
}
handlePermissionModalSuccess(message) {
this.showNotification(message);
this.clearSelection();
this.setState({
showPermissionModal: false,
permissionType: null,
});
}
handlePermissionModalError(message) {
this.showNotification(message, 'error');
this.setState({
showPermissionModal: false,
permissionType: null,
});
}
handlePlaylistModalCancel() {
this.setState({
showPlaylistModal: false,
});
}
handlePlaylistModalSuccess(message) {
this.showNotification(message);
this.clearSelection();
this.setState({
showPlaylistModal: false,
});
}
handlePlaylistModalError(message) {
this.showNotification(message, 'error');
this.setState({
showPlaylistModal: false,
});
}
handleChangeOwnerModalCancel() {
this.setState({
showChangeOwnerModal: false,
});
}
handleChangeOwnerModalSuccess(message) {
this.showNotification(message);
this.clearSelectionAndRefresh();
this.setState({
showChangeOwnerModal: false,
});
}
handleChangeOwnerModalError(message) {
this.showNotification(message, 'error');
this.setState({
showChangeOwnerModal: false,
});
}
handlePublishStateModalCancel() {
this.setState({
showPublishStateModal: false,
});
}
handlePublishStateModalSuccess(message) {
this.showNotification(message);
this.clearSelectionAndRefresh();
this.setState({
showPublishStateModal: false,
});
}
handlePublishStateModalError(message) {
this.showNotification(message, 'error');
this.setState({
showPublishStateModal: false,
});
}
handleCategoryModalCancel() {
this.setState({
showCategoryModal: false,
});
}
handleCategoryModalSuccess(message) {
this.showNotification(message);
this.clearSelection();
this.setState({
showCategoryModal: false,
});
}
handleCategoryModalError(message) {
this.showNotification(message, 'error');
this.setState({
showCategoryModal: false,
});
}
handleTagModalCancel() {
this.setState({
showTagModal: false,
});
}
handleTagModalSuccess(message) {
this.showNotification(message);
this.clearSelection();
this.setState({
showTagModal: false,
});
}
handleTagModalError(message) {
this.showNotification(message, 'error');
this.setState({
showTagModal: false,
});
}
onResponseDataLoaded(responseData) {
// Extract tags from response
if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
this.setState({ availableTags: tags });
}
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active (excluding default sort and tags)
const hasActiveFilters = this.state.filterArgs && (
this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=')
);
const hasActiveTags = this.state.selectedTag && this.state.selectedTag !== 'all';
const hasActiveSort = this.state.selectedSort && this.state.selectedSort !== 'date_added_desc';
return [
this.state.author ? (
<ProfilePagesHeader
key="ProfilePagesHeader"
type="media"
author={this.state.author}
onQueryChange={this.changeRequestQuery}
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={hasActiveTags}
hasActiveSort={hasActiveSort}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper
title={!isMediaAuthor || 0 < this.state.channelMediaCount ? this.state.title : null}
title={this.state.title}
className="items-list-ver"
showBulkActions={isMediaAuthor}
selectedCount={this.state.selectedMedia.size}
totalCount={this.state.availableMediaIds.length}
onBulkAction={this.handleBulkAction}
onSelectAll={this.handleSelectAll}
onDeselectAll={this.handleDeselectAll}
>
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} selectedTag={this.state.selectedTag} selectedSort={this.state.selectedSort} />
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} />
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync
key={this.state.requestUrl}
key={`${this.state.requestUrl}-${this.state.listKey}`}
requestUrl={this.state.requestUrl}
hideAuthor={true}
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={isMediaAuthor}
showSelection={isMediaAuthor}
hasAnySelection={this.state.selectedMedia.size > 0}
selectedMedia={this.state.selectedMedia}
onMediaSelection={this.handleMediaSelection}
onItemsUpdate={this.handleItemsUpdate}
onResponseDataLoaded={this.onResponseDataLoaded}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptyChannelMedia name={this.state.author.name} />
) : null}
</MediaListWrapper>
</ProfilePagesContent>
) : null,
<BulkActionConfirmModal
key="BulkActionConfirmModal"
isOpen={this.state.showConfirmModal}
message={this.state.confirmMessage}
onCancel={this.handleConfirmCancel}
onProceed={this.handleConfirmProceed}
/>,
<BulkActionPermissionModal
key="BulkActionPermissionModal"
isOpen={this.state.showPermissionModal}
permissionType={this.state.permissionType}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handlePermissionModalCancel}
onSuccess={this.handlePermissionModalSuccess}
onError={this.handlePermissionModalError}
csrfToken={this.getCsrfToken()}
/>,
<BulkActionPlaylistModal
key="BulkActionPlaylistModal"
isOpen={this.state.showPlaylistModal}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handlePlaylistModalCancel}
onSuccess={this.handlePlaylistModalSuccess}
onError={this.handlePlaylistModalError}
csrfToken={this.getCsrfToken()}
username={this.state.author ? this.state.author.username : ''}
/>,
<BulkActionChangeOwnerModal
key="BulkActionChangeOwnerModal"
isOpen={this.state.showChangeOwnerModal}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handleChangeOwnerModalCancel}
onSuccess={this.handleChangeOwnerModalSuccess}
onError={this.handleChangeOwnerModalError}
csrfToken={this.getCsrfToken()}
/>,
<BulkActionPublishStateModal
key="BulkActionPublishStateModal"
isOpen={this.state.showPublishStateModal}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handlePublishStateModalCancel}
onSuccess={this.handlePublishStateModalSuccess}
onError={this.handlePublishStateModalError}
csrfToken={this.getCsrfToken()}
/>,
<BulkActionCategoryModal
key="BulkActionCategoryModal"
isOpen={this.state.showCategoryModal}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handleCategoryModalCancel}
onSuccess={this.handleCategoryModalSuccess}
onError={this.handleCategoryModalError}
csrfToken={this.getCsrfToken()}
/>,
<BulkActionTagModal
key="BulkActionTagModal"
isOpen={this.state.showTagModal}
selectedMediaIds={Array.from(this.state.selectedMedia)}
onCancel={this.handleTagModalCancel}
onSuccess={this.handleTagModalSuccess}
onError={this.handleTagModalError}
csrfToken={this.getCsrfToken()}
/>,
this.state.showNotification ? (
<div
key="SimpleNotification"
style={{
position: 'fixed',
bottom: '20px',
left: '260px',
backgroundColor: this.state.notificationType === 'error' ? '#f44336' : '#4CAF50',
color: 'white',
padding: '16px 24px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
fontSize: '14px',
fontWeight: '500',
}}
>
{this.state.notificationMessage}
</div>
) : null,
];
}
}

View File

@@ -0,0 +1,423 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
import { PageStore, ProfilePageStore } from '../utils/stores';
import { ProfilePageActions } from '../utils/actions';
import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { BulkActionsModals } from '../components/BulkActionsModals';
import { translateString } from '../utils/helpers';
import { withBulkActions } from '../utils/hoc/withBulkActions';
import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss';
function EmptySharedByMe(props) {
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">
Media that you have shared with others will show up here.
</div>
</div>
)}
</LinksConsumer>
);
}
class ProfileSharedByMePage extends Page {
constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me');
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me';
this.state = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
let requestUrl = this.state.requestUrl;
if (author) {
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me' + this.state.filterArgs;
}
}
this.setState({
author: author,
requestUrl: requestUrl,
});
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) {
title = 'No results for "' + this.state.query + '"';
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
}
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
}
let requestUrl;
if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
}
let title = this.state.title;
if ('' === newQuery) {
title = this.props.title;
}
this.setState({
requestUrl: requestUrl,
query: newQuery,
title: title,
});
}
onToggleFiltersClick() {
this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
onTagSelect(tag) {
this.setState({ selectedTag: tag }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
});
});
}
onSortSelect(sortBy) {
this.setState({ selectedSort: sortBy }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
});
});
}
onFiltersUpdate(updatedArgs) {
const args = {
media_type: null,
upload_date: null,
duration: null,
publish_state: null,
sort_by: null,
ordering: null,
t: null,
};
switch (updatedArgs.media_type) {
case 'video':
case 'audio':
case 'image':
case 'pdf':
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) {
return;
}
let requestUrl;
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
}
this.setState({
requestUrl: requestUrl,
});
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
this.setState({ availableTags: tags });
}
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active
const hasActiveFilters = this.state.filterArgs && (
this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=')
);
return [
this.state.author ? (
<ProfilePagesHeader
key="ProfilePagesHeader"
author={this.state.author}
type="shared_by_me"
onQueryChange={this.changeRequestQuery}
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper
title={this.state.title}
className="items-list-ver"
showBulkActions={isMediaAuthor}
selectedCount={this.props.bulkActions.selectedMedia.size}
totalCount={this.props.bulkActions.availableMediaIds.length}
onBulkAction={this.props.bulkActions.handleBulkAction}
onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
>
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} />
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} />
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
requestUrl={this.state.requestUrl}
hideAuthor={true}
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={isMediaAuthor}
onResponseDataLoaded={this.onResponseDataLoaded}
showSelection={isMediaAuthor}
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
selectedMedia={this.props.bulkActions.selectedMedia}
onMediaSelection={this.props.bulkActions.handleMediaSelection}
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedByMe name={this.state.author.name} />
) : null}
</MediaListWrapper>
</ProfilePagesContent>
) : null,
this.state.author && isMediaAuthor ? (
<BulkActionsModals
key="BulkActionsModals"
{...this.props.bulkActions}
selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)}
csrfToken={this.props.bulkActions.getCsrfToken()}
username={this.state.author.username}
onConfirmCancel={this.props.bulkActions.handleConfirmCancel}
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel}
onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess}
onPermissionModalError={this.props.bulkActions.handlePermissionModalError}
onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel}
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError}
onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel}
onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess}
onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError}
onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel}
onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess}
onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError}
onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel}
onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess}
onCategoryModalError={this.props.bulkActions.handleCategoryModalError}
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
onTagModalError={this.props.bulkActions.handleTagModalError}
/>
) : null,
];
}
}
ProfileSharedByMePage.propTypes = {
title: PropTypes.string.isRequired,
bulkActions: PropTypes.object.isRequired,
};
ProfileSharedByMePage.defaultProps = {
title: 'Shared by me',
};
// Wrap with HOC and export as named export for compatibility
const WrappedProfileSharedByMePage = withBulkActions(ProfileSharedByMePage);
// Export both the wrapped component as named export (for build system) and default export
export { WrappedProfileSharedByMePage as ProfileSharedByMePage };
export default WrappedProfileSharedByMePage;

View File

@@ -0,0 +1,373 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
import { PageStore, ProfilePageStore } from '../utils/stores';
import { ProfilePageActions } from '../utils/actions';
import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListAsync';
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { translateString } from '../utils/helpers';
import { Page } from './_Page';
import '../components/profile-page/ProfilePage.scss';
function EmptySharedWithMe(props) {
return (
<LinksConsumer>
{(links) => (
<div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div>
<div className="start-uploading">
Media that others have shared with you will show up here.
</div>
</div>
)}
</LinksConsumer>
);
}
export class ProfileSharedWithMePage extends Page {
constructor(props, pageSlug) {
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me');
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me';
this.state = {
channelMediaCount: -1,
author: ProfilePageStore.get('author-data'),
uploadsPreviewItemsCount: 0,
title: this.props.title,
query: ProfilePageStore.get('author-query'),
requestUrl: null,
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: true,
filterArgs: '',
availableTags: [],
selectedTag: 'all',
selectedSort: 'date_added_desc',
};
this.authorDataLoad = this.authorDataLoad.bind(this);
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
this.getCountFunc = this.getCountFunc.bind(this);
this.changeRequestQuery = this.changeRequestQuery.bind(this);
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
this.onTagSelect = this.onTagSelect.bind(this);
this.onSortSelect = this.onSortSelect.bind(this);
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
ProfilePageStore.on('load-author-data', this.authorDataLoad);
}
componentDidMount() {
ProfilePageActions.load_author_data();
}
authorDataLoad() {
const author = ProfilePageStore.get('author-data');
let requestUrl = this.state.requestUrl;
if (author) {
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me' + this.state.filterArgs;
}
}
this.setState({
author: author,
requestUrl: requestUrl,
});
}
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
this.setState({
uploadsPreviewItemsCount: totalAuthorPreviewItems,
});
}
getCountFunc(count) {
this.setState(
{
channelMediaCount: count,
},
() => {
if (this.state.query) {
let title = '';
if (!count) {
title = 'No results for "' + this.state.query + '"';
} else if (1 === count) {
title = '1 result for "' + this.state.query + '"';
} else {
title = count + ' results for "' + this.state.query + '"';
}
this.setState({
title: title,
});
}
}
);
}
changeRequestQuery(newQuery) {
if (!this.state.author) {
return;
}
let requestUrl;
if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
}
let title = this.state.title;
if ('' === newQuery) {
title = this.props.title;
}
this.setState({
requestUrl: requestUrl,
query: newQuery,
title: title,
});
}
onToggleFiltersClick() {
this.setState({
hiddenFilters: !this.state.hiddenFilters,
hiddenTags: true,
hiddenSorting: true,
});
}
onToggleTagsClick() {
this.setState({
hiddenFilters: true,
hiddenTags: !this.state.hiddenTags,
hiddenSorting: true,
});
}
onToggleSortingClick() {
this.setState({
hiddenFilters: true,
hiddenTags: true,
hiddenSorting: !this.state.hiddenSorting,
});
}
onTagSelect(tag) {
this.setState({ selectedTag: tag }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: this.state.selectedSort,
tag: tag,
});
});
}
onSortSelect(sortBy) {
this.setState({ selectedSort: sortBy }, () => {
this.onFiltersUpdate({
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
sort_by: sortBy,
tag: this.state.selectedTag,
});
});
}
onFiltersUpdate(updatedArgs) {
const args = {
media_type: null,
upload_date: null,
duration: null,
publish_state: null,
sort_by: null,
ordering: null,
t: null,
};
switch (updatedArgs.media_type) {
case 'video':
case 'audio':
case 'image':
case 'pdf':
args.media_type = updatedArgs.media_type;
break;
}
switch (updatedArgs.upload_date) {
case 'today':
case 'this_week':
case 'this_month':
case 'this_year':
args.upload_date = updatedArgs.upload_date;
break;
}
// Handle duration filter
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
args.duration = updatedArgs.duration;
}
// Handle publish state filter
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
args.publish_state = updatedArgs.publish_state;
}
switch (updatedArgs.sort_by) {
case 'date_added_desc':
// Default sorting, no need to add parameters
break;
case 'date_added_asc':
args.ordering = 'asc';
break;
case 'alphabetically_asc':
args.sort_by = 'title_asc';
break;
case 'alphabetically_desc':
args.sort_by = 'title_desc';
break;
case 'plays_least':
args.sort_by = 'views_asc';
break;
case 'plays_most':
args.sort_by = 'views_desc';
break;
case 'likes_least':
args.sort_by = 'likes_asc';
break;
case 'likes_most':
args.sort_by = 'likes_desc';
break;
}
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
args.t = updatedArgs.tag;
}
const newArgs = [];
for (let arg in args) {
if (null !== args[arg]) {
newArgs.push(arg + '=' + args[arg]);
}
}
this.setState(
{
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
},
function () {
if (!this.state.author) {
return;
}
let requestUrl;
if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
} else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
}
this.setState({
requestUrl: requestUrl,
});
}
);
}
onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
this.setState({ availableTags: tags });
}
}
pageContent() {
const authorData = ProfilePageStore.get('author-data');
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active
const hasActiveFilters = this.state.filterArgs && (
this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=')
);
return [
this.state.author ? (
<ProfilePagesHeader
key="ProfilePagesHeader"
author={this.state.author}
type="shared_with_me"
onQueryChange={this.changeRequestQuery}
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
/>
) : null,
this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper
title={this.state.title}
className="items-list-ver"
>
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} />
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} />
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync
key={this.state.requestUrl}
requestUrl={this.state.requestUrl}
hideAuthor={true}
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
hideViews={!PageStore.get('config-media-item').displayViews}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
canEdit={false}
onResponseDataLoaded={this.onResponseDataLoaded}
/>
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
<EmptySharedWithMe name={this.state.author.name} />
) : null}
</MediaListWrapper>
</ProfilePagesContent>
) : null,
];
}
}
ProfileSharedWithMePage.propTypes = {
title: PropTypes.string.isRequired,
};
ProfileSharedWithMePage.defaultProps = {
title: 'Shared with me',
};

View File

@@ -14,6 +14,8 @@ export * from './PlaylistPage';
export * from './ProfileAboutPage';
export * from './ProfileMediaPage';
export * from './ProfilePlaylistsPage';
export * from './ProfileSharedByMePage';
export * from './ProfileSharedWithMePage';
export * from './RecommendedMediaPage';
export * from './SearchPage';
export * from './TagsPage';

View File

@@ -9,4 +9,5 @@ export * from './useMediaFilter';
export * from './useMediaItem';
export * from './usePopup';
export * from './useTheme';
export * from './useUser';
export * from './useUser';
export * from './useBulkActions';

View File

@@ -0,0 +1,516 @@
import { useState } from 'react';
import { translateString } from '../helpers';
/**
* Custom hook for managing bulk actions on media items
* Provides state management and handlers for selecting media and executing bulk actions
*/
export function useBulkActions() {
const [selectedMedia, setSelectedMedia] = useState(new Set());
const [availableMediaIds, setAvailableMediaIds] = useState([]);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
const [confirmMessage, setConfirmMessage] = useState('');
const [listKey, setListKey] = useState(0);
const [notificationMessage, setNotificationMessage] = useState('');
const [showNotification, setShowNotification] = useState(false);
const [notificationType, setNotificationType] = useState('success');
const [showPermissionModal, setShowPermissionModal] = useState(false);
const [permissionType, setPermissionType] = useState(null);
const [showPlaylistModal, setShowPlaylistModal] = useState(false);
const [showChangeOwnerModal, setShowChangeOwnerModal] = useState(false);
const [showPublishStateModal, setShowPublishStateModal] = useState(false);
const [showCategoryModal, setShowCategoryModal] = useState(false);
const [showTagModal, setShowTagModal] = useState(false);
// Get CSRF token from cookies
const getCsrfToken = () => {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === name + '=') {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
// Show notification
const showNotificationMessage = (message, type = 'success') => {
setNotificationMessage(message);
setShowNotification(true);
setNotificationType(type);
setTimeout(() => {
setShowNotification(false);
}, 5000);
};
// Handle media selection toggle
const handleMediaSelection = (mediaId, isSelected) => {
setSelectedMedia((prevState) => {
const newSelectedMedia = new Set(prevState);
if (isSelected) {
newSelectedMedia.add(mediaId);
} else {
newSelectedMedia.delete(mediaId);
}
return newSelectedMedia;
});
};
// Handle items update from list
const handleItemsUpdate = (items) => {
const mediaIds = items.map((item) => item.friendly_token || item.uid || item.id);
setAvailableMediaIds(mediaIds);
};
// Select all available media
const handleSelectAll = () => {
setSelectedMedia(new Set(availableMediaIds));
};
// Deselect all media
const handleDeselectAll = () => {
setSelectedMedia(new Set());
};
// Clear selection
const clearSelection = () => {
setSelectedMedia(new Set());
};
// Clear selection and refresh list
const clearSelectionAndRefresh = () => {
setSelectedMedia(new Set());
setListKey((prev) => prev + 1);
};
// Handle bulk action button clicks
const handleBulkAction = (action) => {
const selectedCount = selectedMedia.size;
if (selectedCount === 0) {
return;
}
if (action === 'delete-media') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to delete') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'enable-comments') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to enable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'disable-comments') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to disable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'enable-download') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to enable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'disable-download') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to disable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'copy-media') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to copy') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'add-remove-coviewers') {
setShowPermissionModal(true);
setPermissionType('viewer');
} else if (action === 'add-remove-coeditors') {
setShowPermissionModal(true);
setPermissionType('editor');
} else if (action === 'add-remove-coowners') {
setShowPermissionModal(true);
setPermissionType('owner');
} else if (action === 'add-remove-playlist') {
setShowPlaylistModal(true);
} else if (action === 'change-owner') {
setShowChangeOwnerModal(true);
} else if (action === 'publish-state') {
setShowPublishStateModal(true);
} else if (action === 'add-remove-category') {
setShowCategoryModal(true);
} else if (action === 'add-remove-tags') {
setShowTagModal(true);
}
};
// Cancel confirm modal
const handleConfirmCancel = () => {
setShowConfirmModal(false);
setPendingAction(null);
setConfirmMessage('');
};
// Proceed with confirmed action
const handleConfirmProceed = () => {
const action = pendingAction;
setShowConfirmModal(false);
setPendingAction(null);
setConfirmMessage('');
if (action === 'delete-media') {
executeDeleteMedia();
} else if (action === 'enable-comments') {
executeEnableComments();
} else if (action === 'disable-comments') {
executeDisableComments();
} else if (action === 'enable-download') {
executeEnableDownload();
} else if (action === 'disable-download') {
executeDisableDownload();
} else if (action === 'copy-media') {
executeCopyMedia();
}
};
// Execute delete media
const executeDeleteMedia = () => {
const selectedIds = Array.from(selectedMedia);
const selectedCount = selectedIds.length;
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'delete_media',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete media');
}
return response.json();
})
.then((data) => {
const message = selectedCount === 1
? translateString('The media was deleted successfully.')
: translateString('Successfully deleted') + ` ${selectedCount} ` + translateString('media.');
showNotificationMessage(message);
clearSelectionAndRefresh();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to delete media. Please try again.'), 'error');
clearSelectionAndRefresh();
});
};
// Execute enable comments
const executeEnableComments = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'enable_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to enable comments');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully Enabled comments'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to enable comments.'), 'error');
clearSelection();
});
};
// Execute disable comments
const executeDisableComments = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'disable_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to disable comments');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully Disabled comments'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to disable comments.'), 'error');
clearSelection();
});
};
// Execute enable download
const executeEnableDownload = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'enable_download',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to enable download');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully Enabled Download'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to enable download.'), 'error');
clearSelection();
});
};
// Execute disable download
const executeDisableDownload = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'disable_download',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to disable download');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully Disabled Download'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to disable download.'), 'error');
clearSelection();
});
};
// Execute copy media
const executeCopyMedia = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'copy_media',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to copy media');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully Copied'));
clearSelectionAndRefresh();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to copy media.'), 'error');
clearSelection();
});
};
// Permission modal handlers
const handlePermissionModalCancel = () => {
setShowPermissionModal(false);
setPermissionType(null);
};
const handlePermissionModalSuccess = (message) => {
showNotificationMessage(message);
clearSelection();
setShowPermissionModal(false);
setPermissionType(null);
};
const handlePermissionModalError = (message) => {
showNotificationMessage(message, 'error');
setShowPermissionModal(false);
setPermissionType(null);
};
// Playlist modal handlers
const handlePlaylistModalCancel = () => {
setShowPlaylistModal(false);
};
const handlePlaylistModalSuccess = (message) => {
showNotificationMessage(message);
clearSelection();
setShowPlaylistModal(false);
};
const handlePlaylistModalError = (message) => {
showNotificationMessage(message, 'error');
setShowPlaylistModal(false);
};
// Change owner modal handlers
const handleChangeOwnerModalCancel = () => {
setShowChangeOwnerModal(false);
};
const handleChangeOwnerModalSuccess = (message) => {
showNotificationMessage(message);
clearSelectionAndRefresh();
setShowChangeOwnerModal(false);
};
const handleChangeOwnerModalError = (message) => {
showNotificationMessage(message, 'error');
setShowChangeOwnerModal(false);
};
// Publish state modal handlers
const handlePublishStateModalCancel = () => {
setShowPublishStateModal(false);
};
const handlePublishStateModalSuccess = (message) => {
showNotificationMessage(message);
clearSelectionAndRefresh();
setShowPublishStateModal(false);
};
const handlePublishStateModalError = (message) => {
showNotificationMessage(message, 'error');
setShowPublishStateModal(false);
};
// Category modal handlers
const handleCategoryModalCancel = () => {
setShowCategoryModal(false);
};
const handleCategoryModalSuccess = (message) => {
showNotificationMessage(message);
clearSelection();
setShowCategoryModal(false);
};
const handleCategoryModalError = (message) => {
showNotificationMessage(message, 'error');
setShowCategoryModal(false);
};
// Tag modal handlers
const handleTagModalCancel = () => {
setShowTagModal(false);
};
const handleTagModalSuccess = (message) => {
showNotificationMessage(message);
clearSelection();
setShowTagModal(false);
};
const handleTagModalError = (message) => {
showNotificationMessage(message, 'error');
setShowTagModal(false);
};
return {
// State
selectedMedia,
availableMediaIds,
listKey,
showConfirmModal,
confirmMessage,
notificationMessage,
showNotification,
notificationType,
showPermissionModal,
permissionType,
showPlaylistModal,
showChangeOwnerModal,
showPublishStateModal,
showCategoryModal,
showTagModal,
// Handlers
handleMediaSelection,
handleItemsUpdate,
handleSelectAll,
handleDeselectAll,
handleBulkAction,
handleConfirmCancel,
handleConfirmProceed,
handlePermissionModalCancel,
handlePermissionModalSuccess,
handlePermissionModalError,
handlePlaylistModalCancel,
handlePlaylistModalSuccess,
handlePlaylistModalError,
handleChangeOwnerModalCancel,
handleChangeOwnerModalSuccess,
handleChangeOwnerModalError,
handlePublishStateModalCancel,
handlePublishStateModalSuccess,
handlePublishStateModalError,
handleCategoryModalCancel,
handleCategoryModalSuccess,
handleCategoryModalError,
handleTagModalCancel,
handleTagModalSuccess,
handleTagModalError,
// Utility
getCsrfToken,
clearSelection,
clearSelectionAndRefresh,
};
}

View File

@@ -8,6 +8,7 @@ import {
MediaItemMetaViews,
MediaItemMetaDate,
MediaItemEditLink,
MediaItemViewLink,
} from '../../components/list-item/includes/items';
import { useItem } from './useItem';
import { replaceString } from '../../utils/helpers/';
@@ -33,6 +34,10 @@ export function useMediaItem(props) {
return <MediaItemEditLink link={props.editLink} />;
}
function viewMediaComponent() {
return props.showSelection ? <MediaItemViewLink link={props.publishLink || props.link} /> : null;
}
function authorComponent() {
if (props.hideAuthor) {
return null;
@@ -76,5 +81,5 @@ export function useMediaItem(props) {
);
}
return [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents];
return [titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper, editMediaComponent, metaComponents, viewMediaComponent];
}

View File

@@ -47,11 +47,15 @@ export function config(glbl) {
media: glbl.user.pages.media,
about: glbl.user.pages.about,
playlists: glbl.user.pages.playlists,
shared_by_me: glbl.user.pages.media + '/shared_by_me',
shared_with_me: glbl.user.pages.media + '/shared_with_me',
}
: {
media: glbl.site.url.replace(/\/$/, '') + '/user/' + glbl.profileId,
about: glbl.site.url.replace(/\/$/, '') + '/user/' + glbl.profileId + '/about',
playlists: glbl.site.url.replace(/\/$/, '') + '/user/' + glbl.profileId + '/playlists',
shared_by_me: glbl.site.url.replace(/\/$/, '') + '/user/' + glbl.profileId + '/shared_by_me',
shared_with_me: glbl.site.url.replace(/\/$/, '') + '/user/' + glbl.profileId + '/shared_with_me',
},
user: {
liked: glbl.url.likedMedia,

File diff suppressed because it is too large Load Diff