Bulk actions support (#1418)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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