Add leftover media management, PDF to PNG conversion, video delete, and playlist duration editing
Features added: - Leftover media management page in admin panel with delete functionality for images and videos - Individual file delete buttons for leftover media - PDF to PNG conversion (each page becomes a separate image at Full HD resolution) - Delete functionality for leftover video files - Enhanced playlist management with duration editing for all content types - Improved dark mode support for playlist management page - Content type badges with color coding - Better styling for duration input fields with save functionality - Fixed URL generation for duration update endpoint
This commit is contained in:
@@ -490,3 +490,29 @@ def delete_leftover_videos():
|
|||||||
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('admin.leftover_media'))
|
return redirect(url_for('admin.leftover_media'))
|
||||||
|
|
||||||
|
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_single_leftover(content_id):
|
||||||
|
"""Delete a single leftover content file"""
|
||||||
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
|
||||||
|
# Delete physical file
|
||||||
|
if content.file_path:
|
||||||
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete database record
|
||||||
|
db.session.delete(content)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Successfully deleted {content.file_path}', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting file: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.leftover_media'))
|
||||||
|
|||||||
@@ -295,17 +295,58 @@ def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str
|
|||||||
|
|
||||||
|
|
||||||
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
"""Process PDF files."""
|
"""Process PDF files by converting each page to PNG images."""
|
||||||
try:
|
try:
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Basic PDF validation - check if it's a valid PDF
|
# Basic PDF validation - check if it's a valid PDF
|
||||||
with open(filepath, 'rb') as f:
|
with open(filepath, 'rb') as f:
|
||||||
header = f.read(5)
|
header = f.read(5)
|
||||||
if header != b'%PDF-':
|
if header != b'%PDF-':
|
||||||
return False, "Invalid PDF file"
|
return False, "Invalid PDF file"
|
||||||
|
|
||||||
log_action('info', f'PDF validated: {filename}')
|
log_action('info', f'Converting PDF to images: {filename}')
|
||||||
return True, "PDF processed successfully"
|
|
||||||
|
# Convert PDF pages to images at Full HD resolution
|
||||||
|
images = convert_from_path(
|
||||||
|
filepath,
|
||||||
|
dpi=150, # Good quality for Full HD display
|
||||||
|
fmt='png',
|
||||||
|
size=(1920, 1080) # Direct Full HD output
|
||||||
|
)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
return False, "No pages found in PDF"
|
||||||
|
|
||||||
|
# Generate base filename without extension
|
||||||
|
base_filename = Path(filename).stem
|
||||||
|
upload_folder = os.path.dirname(filepath)
|
||||||
|
|
||||||
|
# Save each page directly as PNG
|
||||||
|
converted_files = []
|
||||||
|
for idx, image in enumerate(images, start=1):
|
||||||
|
# Create filename for this page
|
||||||
|
page_filename = f"{base_filename}_page{idx:03d}.png"
|
||||||
|
page_filepath = os.path.join(upload_folder, page_filename)
|
||||||
|
|
||||||
|
# Save the image directly without additional optimization
|
||||||
|
image.save(page_filepath, 'PNG', optimize=True)
|
||||||
|
|
||||||
|
converted_files.append((page_filepath, page_filename))
|
||||||
|
log_action('info', f'Converted PDF page {idx}/{len(images)}: {page_filename}')
|
||||||
|
|
||||||
|
log_action('info', f'PDF converted successfully: {len(images)} pages from {filename}')
|
||||||
|
|
||||||
|
# Return success with file info for later processing
|
||||||
|
return True, f"PDF converted to {len(images)} images"
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return False, "pdf2image library not installed. Install with: pip install pdf2image"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_details = traceback.format_exc()
|
||||||
|
log_action('error', f'PDF processing error: {str(e)}\n{error_details}')
|
||||||
return False, f"PDF processing error: {str(e)}"
|
return False, f"PDF processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -421,32 +462,38 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
return False, f"Presentation processing error: {str(e)}"
|
return False, f"Presentation processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_fullhd_image(img):
|
||||||
|
"""Create a Full HD (1920x1080) image from PIL Image object, centered on white background."""
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
target_size = (1920, 1080)
|
||||||
|
|
||||||
|
# Resize maintaining aspect ratio
|
||||||
|
img_copy = img.copy()
|
||||||
|
img_copy.thumbnail(target_size, PILImage.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Create canvas with white background
|
||||||
|
fullhd_img = PILImage.new('RGB', target_size, (255, 255, 255))
|
||||||
|
|
||||||
|
# Center the image
|
||||||
|
x = (target_size[0] - img_copy.width) // 2
|
||||||
|
y = (target_size[1] - img_copy.height) // 2
|
||||||
|
|
||||||
|
if img_copy.mode == 'RGBA':
|
||||||
|
fullhd_img.paste(img_copy, (x, y), img_copy)
|
||||||
|
else:
|
||||||
|
fullhd_img.paste(img_copy, (x, y))
|
||||||
|
|
||||||
|
return fullhd_img
|
||||||
|
|
||||||
|
|
||||||
def optimize_image_to_fullhd(filepath: str) -> bool:
|
def optimize_image_to_fullhd(filepath: str) -> bool:
|
||||||
"""Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio."""
|
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
img = Image.open(filepath)
|
img = Image.open(filepath)
|
||||||
|
fullhd_img = create_fullhd_image(img)
|
||||||
# Target Full HD resolution
|
|
||||||
target_size = (1920, 1080)
|
|
||||||
|
|
||||||
# Calculate resize maintaining aspect ratio
|
|
||||||
img.thumbnail(target_size, Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Create Full HD canvas with white background
|
|
||||||
fullhd_img = Image.new('RGB', target_size, (255, 255, 255))
|
|
||||||
|
|
||||||
# Center the image on the canvas
|
|
||||||
x = (target_size[0] - img.width) // 2
|
|
||||||
y = (target_size[1] - img.height) // 2
|
|
||||||
|
|
||||||
if img.mode == 'RGBA':
|
|
||||||
fullhd_img.paste(img, (x, y), img)
|
|
||||||
else:
|
|
||||||
fullhd_img.paste(img, (x, y))
|
|
||||||
|
|
||||||
# Save optimized image
|
|
||||||
fullhd_img.save(filepath, 'PNG', optimize=True)
|
fullhd_img.save(filepath, 'PNG', optimize=True)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -510,6 +557,61 @@ def upload_media():
|
|||||||
detected_type = 'pdf'
|
detected_type = 'pdf'
|
||||||
processing_success, processing_message = process_pdf_file(filepath, filename)
|
processing_success, processing_message = process_pdf_file(filepath, filename)
|
||||||
|
|
||||||
|
# For PDFs, pages are converted to individual images
|
||||||
|
# We need to add each page image as a separate content item
|
||||||
|
if processing_success and "converted to" in processing_message.lower():
|
||||||
|
# Find all page images that were created
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
page_pattern = f"{base_name}_page*.png"
|
||||||
|
import glob
|
||||||
|
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
|
||||||
|
|
||||||
|
if page_files:
|
||||||
|
max_position = 0
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
|
.scalar() or 0
|
||||||
|
|
||||||
|
# Add each page as separate content
|
||||||
|
for page_file in page_files:
|
||||||
|
page_filename = os.path.basename(page_file)
|
||||||
|
|
||||||
|
# Create content record for page
|
||||||
|
page_content = Content(
|
||||||
|
filename=page_filename,
|
||||||
|
content_type='image',
|
||||||
|
duration=duration,
|
||||||
|
file_size=os.path.getsize(page_file)
|
||||||
|
)
|
||||||
|
db.session.add(page_content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Add to playlist if specified
|
||||||
|
if playlist_id:
|
||||||
|
max_position += 1
|
||||||
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=page_content.id,
|
||||||
|
position=max_position,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
# Increment playlist version if pages were added
|
||||||
|
if playlist_id and page_files:
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
# Delete original PDF file
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
log_action('info', f'Removed original PDF after conversion: {filename}')
|
||||||
|
|
||||||
|
continue # Skip normal content creation below
|
||||||
|
|
||||||
elif file_ext in ['ppt', 'pptx']:
|
elif file_ext in ['ppt', 'pptx']:
|
||||||
detected_type = 'pptx'
|
detected_type = 'pptx'
|
||||||
processing_success, processing_message = process_presentation_file(filepath, filename)
|
processing_success, processing_message = process_presentation_file(filepath, filename)
|
||||||
@@ -562,6 +664,11 @@ def upload_media():
|
|||||||
if playlist_id and slide_files:
|
if playlist_id and slide_files:
|
||||||
playlist.version += 1
|
playlist.version += 1
|
||||||
|
|
||||||
|
# Delete original PPTX file
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
log_action('info', f'Removed original PPTX after conversion: {filename}')
|
||||||
|
|
||||||
continue # Skip normal content creation below
|
continue # Skip normal content creation below
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -72,6 +73,11 @@
|
|||||||
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
||||||
<td style="padding: 10px;">{{ img.duration }}s</td>
|
<td style="padding: 10px;">{{ img.duration }}s</td>
|
||||||
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
|
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
|
||||||
|
<td style="padding: 10px;">
|
||||||
|
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -107,6 +113,7 @@
|
|||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -116,6 +123,11 @@
|
|||||||
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
||||||
<td style="padding: 10px;">{{ video.duration }}s</td>
|
<td style="padding: 10px;">{{ video.duration }}s</td>
|
||||||
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
|
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
|
||||||
|
<td style="padding: 10px;">
|
||||||
|
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -217,19 +217,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duration-input {
|
.duration-input {
|
||||||
width: 80px;
|
width: 70px !important;
|
||||||
|
padding: 5px 8px !important;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
background: white !important;
|
||||||
|
border: 2px solid #ced4da !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-input:hover {
|
.duration-input:hover {
|
||||||
border-color: #667eea;
|
border-color: #667eea !important;
|
||||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-input:focus {
|
.duration-input:focus {
|
||||||
border-color: #667eea;
|
border-color: #667eea !important;
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||||
|
background: white !important;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-duration-btn {
|
.save-duration-btn {
|
||||||
@@ -248,6 +256,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-image {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-video {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pdf {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
body.dark-mode .playlist-section {
|
body.dark-mode .playlist-section {
|
||||||
background: #2d3748;
|
background: #2d3748;
|
||||||
@@ -306,6 +337,38 @@
|
|||||||
body.dark-mode .drag-handle {
|
body.dark-mode .drag-handle {
|
||||||
color: #718096;
|
color: #718096;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-input {
|
||||||
|
background: #1a202c !important;
|
||||||
|
border-color: #4a5568 !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-input:hover {
|
||||||
|
border-color: #667eea !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-input:focus {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
border-color: #667eea !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .badge-image {
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #64b5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .badge-video {
|
||||||
|
background: #4a1e5a;
|
||||||
|
color: #ce93d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .badge-pdf {
|
||||||
|
background: #5a1e1e;
|
||||||
|
color: #ef5350;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="playlist-container">
|
<div class="playlist-container">
|
||||||
@@ -383,10 +446,14 @@
|
|||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>{{ content.filename }}</td>
|
<td>{{ content.filename }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if content.content_type == 'image' %}📷 Image
|
{% if content.content_type == 'image' %}
|
||||||
{% elif content.content_type == 'video' %}🎥 Video
|
<span class="content-type-badge badge-image">📷 Image</span>
|
||||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
{% elif content.content_type == 'video' %}
|
||||||
{% else %}📁 {{ content.content_type }}
|
<span class="content-type-badge badge-video">🎥 Video</span>
|
||||||
|
{% elif content.content_type == 'pdf' %}
|
||||||
|
<span class="content-type-badge badge-pdf">📄 PDF</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="content-type-badge">📁 {{ content.content_type }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -400,14 +467,13 @@
|
|||||||
onclick="event.stopPropagation()"
|
onclick="event.stopPropagation()"
|
||||||
onmousedown="event.stopPropagation()"
|
onmousedown="event.stopPropagation()"
|
||||||
oninput="markDurationChanged({{ content.id }})"
|
oninput="markDurationChanged({{ content.id }})"
|
||||||
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})"
|
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})">
|
||||||
style="width: 60px; padding: 5px 8px;">
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-success btn-sm save-duration-btn"
|
class="btn btn-success btn-sm save-duration-btn"
|
||||||
id="save-btn-{{ content.id }}"
|
id="save-btn-{{ content.id }}"
|
||||||
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
|
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
|
||||||
onmousedown="event.stopPropagation()"
|
onmousedown="event.stopPropagation()"
|
||||||
style="display: none; padding: 5px 10px; font-size: 12px; cursor: pointer;"
|
style="display: none;"
|
||||||
title="Save duration (or press Enter)">
|
title="Save duration (or press Enter)">
|
||||||
💾
|
💾
|
||||||
</button>
|
</button>
|
||||||
@@ -626,7 +692,10 @@ function saveDuration(contentId) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('duration', duration);
|
formData.append('duration', duration);
|
||||||
|
|
||||||
fetch(`{{ url_for("playlist.update_duration", player_id=player.id, content_id=0) }}`.replace('/0', `/${contentId}`), {
|
const playerId = {{ player.id }};
|
||||||
|
const url = `/playlist/${playerId}/update-duration/${contentId}`;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user