added delete option
This commit is contained in:
@@ -311,6 +311,24 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: var(--color-error, #e74c3c);
|
||||
}
|
||||
|
||||
.context-menu-item.danger i {
|
||||
color: var(--color-error, #e74c3c);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
NFO Diagnostics
|
||||
============================================================================ */
|
||||
@@ -415,4 +433,125 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Delete Anime Modal
|
||||
============================================================================ */
|
||||
|
||||
#delete-modal .modal-content {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.delete-modal-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.delete-modal-series-info {
|
||||
background: var(--color-background-subtle);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.delete-modal-series-info p {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.delete-modal-series-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.delete-modal-series-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.delete-modal-series-key {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.delete-modal-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.delete-modal-options label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.delete-modal-options input[type="checkbox"] {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.delete-modal-warning {
|
||||
color: var(--color-error, #e74c3c);
|
||||
font-size: var(--font-size-caption);
|
||||
margin-top: var(--spacing-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.delete-modal-confirm-label {
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.delete-modal-confirm-label strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
#delete-confirm-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-body);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
#delete-confirm-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
#delete-confirm-input.matched {
|
||||
border-color: var(--color-success);
|
||||
background: rgba(46, 204, 113, 0.05);
|
||||
}
|
||||
|
||||
#delete-error {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border: 1px solid var(--color-error, #e74c3c);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-error, #e74c3c);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.delete-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
#delete-confirm-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.ContextMenu.init();
|
||||
AniWorld.DeleteModal.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<span>Anime Settings</span>
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item danger" data-action="delete-anime">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span>Delete Anime</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(menuElement);
|
||||
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
|
||||
// Navigate to anime settings page with this series selected
|
||||
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
|
||||
});
|
||||
|
||||
// Delete Anime - opens the confirmation modal
|
||||
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
|
||||
const key = currentSeriesKey;
|
||||
hide();
|
||||
if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
|
||||
AniWorld.DeleteModal.show(key);
|
||||
} else {
|
||||
console.error('[ContextMenu] DeleteModal not found on AniWorld');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
317
src/server/web/static/js/index/delete-modal.js
Normal file
317
src/server/web/static/js/index/delete-modal.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 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() {
|
||||
// Cancel button
|
||||
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
|
||||
|
||||
// Close on backdrop click
|
||||
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Folder checkbox toggle — show/hide warning
|
||||
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
|
||||
confirmInput.addEventListener('input', function() {
|
||||
var value = confirmInput.value;
|
||||
var isMatch = value === 'delete';
|
||||
confirmBtn.disabled = !isMatch || isSubmitting;
|
||||
confirmInput.classList.toggle('matched', isMatch);
|
||||
});
|
||||
|
||||
// Confirm button
|
||||
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);
|
||||
|
||||
// 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
|
||||
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
|
||||
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
|
||||
|
||||
// Reset state
|
||||
confirmInput.value = '';
|
||||
confirmInput.classList.remove('matched');
|
||||
confirmBtn.disabled = true;
|
||||
isSubmitting = false;
|
||||
errorElement.classList.add('hidden');
|
||||
errorElement.textContent = '';
|
||||
deleteDbCheckbox.checked = true;
|
||||
deleteFolderCheckbox.checked = false;
|
||||
document.getElementById('delete-folder-warning').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
|
||||
};
|
||||
})();
|
||||
@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
|
||||
renderSeries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a series from the local data arrays and re-render the grid.
|
||||
* Called after a successful delete or when receiving series_deleted WS event.
|
||||
* @param {string} key - Series key to remove
|
||||
*/
|
||||
function removeSeries(key) {
|
||||
if (!key) return;
|
||||
|
||||
var removedFromData = false;
|
||||
var removedFromFiltered = false;
|
||||
|
||||
if (seriesData) {
|
||||
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
|
||||
if (dataIdx >= 0) {
|
||||
seriesData.splice(dataIdx, 1);
|
||||
removedFromData = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSeriesData) {
|
||||
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
|
||||
if (filteredIdx >= 0) {
|
||||
filteredSeriesData.splice(filteredIdx, 1);
|
||||
removedFromFiltered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedFromData || removedFromFiltered) {
|
||||
console.info('[SeriesManager] Removed series from local state:', key);
|
||||
renderSeries();
|
||||
} else {
|
||||
console.warn('[SeriesManager] Series not found in local state:', key);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
|
||||
findByKey: findByKey,
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries,
|
||||
updateSeriesKey: updateSeriesKey
|
||||
updateSeriesKey: updateSeriesKey,
|
||||
removeSeries: removeSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -160,6 +160,15 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Series deleted event — remove the card from the UI
|
||||
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
|
||||
console.info('[SocketHandler] Series deleted:', data);
|
||||
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(data.key);
|
||||
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
|
||||
}
|
||||
});
|
||||
|
||||
// Download events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
|
||||
@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
|
||||
// Series events
|
||||
SERIES_UPDATED: 'series_updated',
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
SERIES_DELETED: 'series_deleted',
|
||||
|
||||
// Scheduled scan events
|
||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
|
||||
@@ -693,6 +693,7 @@
|
||||
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
|
||||
</body>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user