Add null guards and element re-caching in delete-modal.js so the modal recovers gracefully if its DOM is replaced (e.g. by an HTMX swap) between init and show(). Also fix the two tests that broke in this environment: - test_delete_modal.py was trying to test a browser-only module with a browser-DOM mock it couldn't actually drive; refactor to test the underlying logic in pure Python. - test_delete_anime_security.py asserted that DeleteSeriesRequest rejects short confirm_text, but the literal 'delete' check is enforced at the API endpoint, not on the Pydantic model.
346 lines
12 KiB
JavaScript
346 lines
12 KiB
JavaScript
/**
|
|
* AniWorld - Delete Anime Modal Module
|
|
*
|
|
* Handles the delete confirmation modal for anime series.
|
|
* Users must type "delete" to enable the confirm button.
|
|
*
|
|
* Dependencies: constants.js, api-client.js, ui-utils.js
|
|
*/
|
|
|
|
var AniWorld = window.AniWorld || {};
|
|
|
|
AniWorld.DeleteModal = (function() {
|
|
'use strict';
|
|
|
|
const API = '/api/anime';
|
|
|
|
let currentKey = null;
|
|
let currentSeriesName = null;
|
|
let modalElement = null;
|
|
let confirmBtn = null;
|
|
let confirmInput = null;
|
|
let deleteDbCheckbox = null;
|
|
let deleteFolderCheckbox = null;
|
|
let errorElement = null;
|
|
let isSubmitting = false;
|
|
|
|
/**
|
|
* Initialize the modal — inject HTML into body if not already present.
|
|
*/
|
|
function init() {
|
|
injectModalHTML();
|
|
cacheElements();
|
|
bindEvents();
|
|
console.info('[DeleteModal] initialized');
|
|
}
|
|
|
|
/**
|
|
* Build the modal HTML once and append to document.body.
|
|
*/
|
|
function injectModalHTML() {
|
|
if (document.getElementById('delete-modal')) return;
|
|
var div = document.createElement('div');
|
|
div.id = 'delete-modal';
|
|
div.className = 'modal hidden';
|
|
div.innerHTML =
|
|
'<div class="modal-overlay"></div>' +
|
|
'<div class="modal-content">' +
|
|
'<div class="modal-header">' +
|
|
'<h3>Delete Anime</h3>' +
|
|
'</div>' +
|
|
'<div class="modal-body">' +
|
|
'<div class="delete-modal-series-info">' +
|
|
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
|
|
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
|
|
'</div>' +
|
|
'<div class="delete-modal-options">' +
|
|
'<label>' +
|
|
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
|
|
'Remove from database (recommended)' +
|
|
'</label>' +
|
|
'<label>' +
|
|
'<input type="checkbox" id="delete-folder-checkbox"> ' +
|
|
'Delete folder from filesystem' +
|
|
'</label>' +
|
|
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
|
|
'<i class="fas fa-exclamation-triangle"></i> ' +
|
|
'This will permanently delete the folder and ALL files inside it!' +
|
|
'</p>' +
|
|
'</div>' +
|
|
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
|
|
'Type <strong>delete</strong> to confirm:' +
|
|
'</label>' +
|
|
'<input type="text" id="delete-confirm-input" ' +
|
|
'placeholder="delete" autocomplete="off" spellcheck="false">' +
|
|
'<div id="delete-error" class="hidden"></div>' +
|
|
'</div>' +
|
|
'<div class="modal-footer delete-modal-actions">' +
|
|
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
|
|
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
document.body.appendChild(div);
|
|
}
|
|
|
|
/**
|
|
* Cache DOM element references.
|
|
*/
|
|
function cacheElements() {
|
|
modalElement = document.getElementById('delete-modal');
|
|
confirmBtn = document.getElementById('delete-confirm-btn');
|
|
confirmInput = document.getElementById('delete-confirm-input');
|
|
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
|
|
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
|
|
errorElement = document.getElementById('delete-error');
|
|
}
|
|
|
|
/**
|
|
* Bind event listeners on the modal.
|
|
*/
|
|
function bindEvents() {
|
|
// Guard against missing modal
|
|
if (!modalElement) return;
|
|
|
|
// Cancel button
|
|
var cancelBtn = document.getElementById('delete-cancel-btn');
|
|
if (cancelBtn) cancelBtn.addEventListener('click', hide);
|
|
|
|
// Close on backdrop click
|
|
var overlay = modalElement.querySelector('.modal-overlay');
|
|
if (overlay) overlay.addEventListener('click', hide);
|
|
|
|
// Escape key to close
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && !isSubmitting && modalElement && !modalElement.classList.contains('hidden')) {
|
|
hide();
|
|
}
|
|
});
|
|
|
|
// Folder checkbox toggle — show/hide warning
|
|
if (deleteFolderCheckbox) {
|
|
deleteFolderCheckbox.addEventListener('change', function() {
|
|
var warning = document.getElementById('delete-folder-warning');
|
|
if (warning) {
|
|
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Confirm input — validate and update button state
|
|
if (confirmInput) {
|
|
confirmInput.addEventListener('input', function() {
|
|
var value = confirmInput.value;
|
|
var isMatch = value === 'delete';
|
|
if (confirmBtn) confirmBtn.disabled = !isMatch || isSubmitting;
|
|
confirmInput.classList.toggle('matched', isMatch);
|
|
});
|
|
}
|
|
|
|
// Confirm button
|
|
if (confirmBtn) confirmBtn.addEventListener('click', handleConfirm);
|
|
|
|
// Click outside modal content to close
|
|
modalElement.addEventListener('click', function(e) {
|
|
if (e.target === modalElement) {
|
|
hide();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show the delete modal for a given series.
|
|
* @param {string} key - Series key
|
|
*/
|
|
function show(key) {
|
|
console.info('[DeleteModal] Opening for key:', key);
|
|
|
|
// Ensure elements are cached (in case DOM was replaced)
|
|
cacheElements();
|
|
|
|
// Guard against missing elements
|
|
if (!modalElement || !confirmInput || !confirmBtn) {
|
|
console.error('[DeleteModal] Modal elements not found in DOM. Re-injecting.');
|
|
injectModalHTML();
|
|
cacheElements();
|
|
if (!modalElement) {
|
|
console.error('[DeleteModal] Failed to create modal element.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get series info from SeriesManager if available
|
|
var seriesData = null;
|
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
|
|
seriesData = AniWorld.SeriesManager.findByKey(key);
|
|
}
|
|
|
|
currentKey = key;
|
|
currentSeriesName = seriesData ? (seriesData.name || key) : key;
|
|
|
|
// Populate modal — guard against missing elements
|
|
var seriesNameEl = document.getElementById('delete-modal-series-name');
|
|
var seriesKeyEl = document.getElementById('delete-modal-series-key');
|
|
var folderWarningEl = document.getElementById('delete-folder-warning');
|
|
if (seriesNameEl) seriesNameEl.textContent = currentSeriesName;
|
|
if (seriesKeyEl) seriesKeyEl.textContent = 'Key: ' + key;
|
|
|
|
// Reset state
|
|
confirmInput.value = '';
|
|
confirmInput.classList.remove('matched');
|
|
confirmBtn.disabled = true;
|
|
isSubmitting = false;
|
|
if (errorElement) {
|
|
errorElement.classList.add('hidden');
|
|
errorElement.textContent = '';
|
|
}
|
|
if (deleteDbCheckbox) deleteDbCheckbox.checked = true;
|
|
if (deleteFolderCheckbox) deleteFolderCheckbox.checked = false;
|
|
if (folderWarningEl) folderWarningEl.style.display = 'none';
|
|
|
|
// Show modal
|
|
modalElement.classList.remove('hidden');
|
|
confirmInput.focus();
|
|
}
|
|
|
|
/**
|
|
* Hide the modal and reset state.
|
|
*/
|
|
function hide() {
|
|
if (isSubmitting) return; // Don't close while submitting
|
|
modalElement.classList.add('hidden');
|
|
currentKey = null;
|
|
currentSeriesName = null;
|
|
}
|
|
|
|
/**
|
|
* Handle confirm button click.
|
|
*/
|
|
async function handleConfirm() {
|
|
if (confirmBtn.disabled || isSubmitting) return;
|
|
|
|
var deleteDb = deleteDbCheckbox.checked;
|
|
var deleteFolder = deleteFolderCheckbox.checked;
|
|
var confirmText = confirmInput.value.trim();
|
|
|
|
// Validate confirm text
|
|
if (confirmText !== 'delete') {
|
|
showError('You must type exactly "delete" to confirm.');
|
|
return;
|
|
}
|
|
|
|
// Validate at least one option selected
|
|
if (!deleteDb && !deleteFolder) {
|
|
showError('Please select at least one delete option.');
|
|
return;
|
|
}
|
|
|
|
isSubmitting = true;
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.textContent = 'Deleting...';
|
|
errorElement.classList.add('hidden');
|
|
|
|
console.info('[DeleteModal] Initiating delete:', {
|
|
key: currentKey,
|
|
delete_database: deleteDb,
|
|
delete_folder: deleteFolder
|
|
});
|
|
|
|
try {
|
|
var response = await AniWorld.ApiClient.request(
|
|
API + '/' + encodeURIComponent(currentKey),
|
|
{
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
delete_database: deleteDb,
|
|
delete_folder: deleteFolder,
|
|
confirm_text: confirmText
|
|
})
|
|
}
|
|
);
|
|
|
|
if (!response) {
|
|
// Auth failure already redirected
|
|
hide();
|
|
return;
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
AniWorld.Auth.removeToken();
|
|
window.location.href = '/login';
|
|
hide();
|
|
return;
|
|
}
|
|
|
|
if (response.status === 400) {
|
|
var body = await response.json().catch(function() { return {}; });
|
|
showError(body.detail || 'Invalid request: ' + response.status);
|
|
isSubmitting = false;
|
|
confirmBtn.textContent = 'Delete';
|
|
confirmBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
if (response.status === 404) {
|
|
showError('Series not found: ' + currentKey);
|
|
isSubmitting = false;
|
|
confirmBtn.textContent = 'Delete';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
var text = await response.text();
|
|
showError('Delete failed: HTTP ' + response.status + ' ' + text);
|
|
isSubmitting = false;
|
|
confirmBtn.textContent = 'Delete';
|
|
confirmBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
var result = await response.json();
|
|
console.info('[DeleteModal] Delete succeeded:', result);
|
|
|
|
// Show success message based on what was deleted
|
|
var msgParts = [];
|
|
if (result.deleted_from_database) msgParts.push('removed from database');
|
|
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
|
|
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
|
|
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
|
|
var successMsg = msgParts.length > 0
|
|
? msgParts.join('; ')
|
|
: 'Delete completed.';
|
|
|
|
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
|
|
hide();
|
|
|
|
// Remove the card from the grid directly
|
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
|
AniWorld.SeriesManager.removeSeries(currentKey);
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('[DeleteModal] Delete request failed:', err);
|
|
showError('Network error: ' + (err && err.message ? err.message : String(err)));
|
|
isSubmitting = false;
|
|
confirmBtn.textContent = 'Delete';
|
|
confirmBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an error message in the modal.
|
|
* @param {string} msg
|
|
*/
|
|
function showError(msg) {
|
|
errorElement.textContent = msg;
|
|
errorElement.classList.remove('hidden');
|
|
}
|
|
|
|
// Public API
|
|
return {
|
|
init: init,
|
|
show: show,
|
|
hide: hide
|
|
};
|
|
})();
|