mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-20 21:46:04 -05:00
Bulk actions support (#1418)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
433
frontend/src/static/js/components/BulkActionCategoryModal.scss
Normal file
433
frontend/src/static/js/components/BulkActionCategoryModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
282
frontend/src/static/js/components/BulkActionCategoryModal.tsx
Normal file
282
frontend/src/static/js/components/BulkActionCategoryModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
frontend/src/static/js/components/BulkActionChangeOwnerModal.tsx
Normal file
180
frontend/src/static/js/components/BulkActionChangeOwnerModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
133
frontend/src/static/js/components/BulkActionConfirmModal.scss
Normal file
133
frontend/src/static/js/components/BulkActionConfirmModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
frontend/src/static/js/components/BulkActionConfirmModal.tsx
Normal file
38
frontend/src/static/js/components/BulkActionConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
466
frontend/src/static/js/components/BulkActionPermissionModal.scss
Normal file
466
frontend/src/static/js/components/BulkActionPermissionModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
340
frontend/src/static/js/components/BulkActionPermissionModal.tsx
Normal file
340
frontend/src/static/js/components/BulkActionPermissionModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
567
frontend/src/static/js/components/BulkActionPlaylistModal.scss
Normal file
567
frontend/src/static/js/components/BulkActionPlaylistModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
342
frontend/src/static/js/components/BulkActionPlaylistModal.tsx
Normal file
342
frontend/src/static/js/components/BulkActionPlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
433
frontend/src/static/js/components/BulkActionTagModal.scss
Normal file
433
frontend/src/static/js/components/BulkActionTagModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
281
frontend/src/static/js/components/BulkActionTagModal.tsx
Normal file
281
frontend/src/static/js/components/BulkActionTagModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
frontend/src/static/js/components/BulkActionsDropdown.scss
Normal file
90
frontend/src/static/js/components/BulkActionsDropdown.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
frontend/src/static/js/components/BulkActionsDropdown.tsx
Normal file
69
frontend/src/static/js/components/BulkActionsDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
201
frontend/src/static/js/components/BulkActionsModals.jsx
Normal file
201
frontend/src/static/js/components/BulkActionsModals.jsx
Normal 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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
frontend/src/static/js/components/SelectAllCheckbox.scss
Normal file
53
frontend/src/static/js/components/SelectAllCheckbox.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
frontend/src/static/js/components/SelectAllCheckbox.tsx
Normal file
50
frontend/src/static/js/components/SelectAllCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
423
frontend/src/static/js/pages/ProfileSharedByMePage.js
Normal file
423
frontend/src/static/js/pages/ProfileSharedByMePage.js
Normal 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;
|
||||
373
frontend/src/static/js/pages/ProfileSharedWithMePage.js
Normal file
373
frontend/src/static/js/pages/ProfileSharedWithMePage.js
Normal 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',
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
516
frontend/src/static/js/utils/hooks/useBulkActions.js
Normal file
516
frontend/src/static/js/utils/hooks/useBulkActions.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
1768
frontend/yarn.lock
1768
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user