mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-23 14:53:50 -05:00
Bulk actions support (#1418)
This commit is contained in:
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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user