368 lines
14 KiB
HTML
368 lines
14 KiB
HTML
{% extends "layout.html" %}
|
|
{% block title %}Settings{% endblock %}
|
|
|
|
{% block content %}
|
|
<h1 style="text-align:center; margin-top: 1rem;">Settings</h1>
|
|
|
|
<div class="settings-container">
|
|
<div class="settings-section">
|
|
<h2>Admin Actions</h2>
|
|
<div class="settings-actions">
|
|
<form action="{{ url_for('main.trigger_image_import') }}" method="get">
|
|
<button type="submit" class="btn primary-btn">Trigger Image Import</button>
|
|
</form>
|
|
|
|
<form action="{{ url_for('main.trigger_thumbnail_generation') }}" method="post" onsubmit="return confirm('Are you sure you want to regenerate all thumbnails?');">
|
|
<button type="submit" class="btn warning-btn">Regenerate Thumbnails</button>
|
|
</form>
|
|
|
|
<form action="{{ url_for('main.reset_db') }}" method="post" onsubmit="return confirm('Are you sure you want to delete all image records? This action cannot be undone.');">
|
|
<button type="submit" class="btn danger-btn">Reset Image Database</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import Filters Section -->
|
|
<div class="settings-container" style="margin-top: 1rem;">
|
|
<div class="settings-section">
|
|
<h2>Import Filters</h2>
|
|
<p class="settings-description">Configure filters to skip certain images during import.</p>
|
|
|
|
<form id="importSettingsForm" class="form-card">
|
|
<div class="form-group">
|
|
<label class="form-label">Minimum Width (px)</label>
|
|
<input type="number" id="minWidth" name="min_width" class="form-input" min="0" value="0" placeholder="0 = no minimum">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Minimum Height (px)</label>
|
|
<input type="number" id="minHeight" name="min_height" class="form-input" min="0" value="0" placeholder="0 = no minimum">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label checkbox-label">
|
|
<input type="checkbox" id="skipTransparent" name="skip_transparent">
|
|
Skip mostly transparent images
|
|
</label>
|
|
<p class="form-help">Useful for filtering out UI elements, overlays, and buttons.</p>
|
|
</div>
|
|
|
|
<div class="form-group" id="transparencyThresholdGroup" style="display: none;">
|
|
<label class="form-label">Transparency Threshold (%)</label>
|
|
<input type="number" id="transparencyThreshold" name="transparency_threshold" class="form-input" min="50" max="100" value="90">
|
|
<p class="form-help">Images with more than this % of transparent pixels will be skipped.</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Duplicate Detection Sensitivity</label>
|
|
<select id="phashThreshold" name="phash_threshold" class="form-input">
|
|
<option value="0">Disabled (import all images)</option>
|
|
<option value="1">Strict (only exact visual matches)</option>
|
|
<option value="2">Normal (slight variations filtered)</option>
|
|
<option value="5">Loose (more variations allowed)</option>
|
|
<option value="10">Very Loose (only obvious duplicates filtered)</option>
|
|
</select>
|
|
<p class="form-help">Controls how similar images must be to be considered duplicates. Higher values allow more variations through.</p>
|
|
</div>
|
|
|
|
<button type="submit" class="btn primary-btn" style="width: 100%;">Save Filter Settings</button>
|
|
</form>
|
|
|
|
<div id="importStats" class="import-stats" style="margin-top: 1rem;">
|
|
<h3>Last Import Statistics</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<span class="stat-value" id="statProcessed">-</span>
|
|
<span class="stat-label">Processed</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-value" id="statImported">-</span>
|
|
<span class="stat-label">Imported</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-value" id="statDimension">-</span>
|
|
<span class="stat-label">Filtered (size)</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-value" id="statTransparency">-</span>
|
|
<span class="stat-label">Filtered (transparent)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtered Deletion Section -->
|
|
<div class="settings-container" style="margin-top: 1rem;">
|
|
<div class="settings-section">
|
|
<h2>Delete Images by Tag</h2>
|
|
<p class="settings-description">Permanently delete all images associated with a specific tag (e.g., remove an artist's entire collection).</p>
|
|
|
|
<form id="deleteByTagForm" class="form-card">
|
|
<div class="form-group">
|
|
<label class="form-label">Select Tag to Delete</label>
|
|
<select id="deleteTagSelect" name="tag_id" class="form-input" required>
|
|
<option value="">-- Select a tag --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="deletePreview" class="delete-preview" style="display: none;">
|
|
<p>This will permanently delete <strong id="deleteCount">0</strong> images and their files.</p>
|
|
</div>
|
|
|
|
<div class="form-group" id="confirmationGroup" style="display: none;">
|
|
<label class="form-label">Type the tag name to confirm: <code id="confirmTagName"></code></label>
|
|
<input type="text" id="deleteConfirmInput" class="form-input" placeholder="Type tag name here..." autocomplete="off">
|
|
<p class="form-help">This action cannot be undone.</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label checkbox-label">
|
|
<input type="checkbox" id="deleteTagAlso" name="delete_tag">
|
|
Also delete the tag itself
|
|
</label>
|
|
</div>
|
|
|
|
<button type="submit" class="btn danger-btn" style="width: 100%;" disabled id="deleteByTagBtn">Delete Images</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-container" style="margin-top: 1rem;">
|
|
<div class="settings-info">
|
|
<p>Use these tools to manually trigger an import or reset the image database. Resetting the database does not delete the actual image files.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.settings-description {
|
|
color: var(--text-muted);
|
|
margin-bottom: 1rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.form-help {
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
.checkbox-label input[type="checkbox"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
accent-color: var(--btn-primary);
|
|
}
|
|
.import-stats {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
.import-stats h3 {
|
|
margin: 0 0 0.75rem 0;
|
|
font-size: 0.95rem;
|
|
color: var(--text-dim);
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 0.75rem;
|
|
}
|
|
.stat-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 6px;
|
|
}
|
|
.stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
.delete-preview {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: 6px;
|
|
padding: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--flash-danger);
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Load import settings
|
|
fetch('/api/import-settings')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.ok) {
|
|
document.getElementById('minWidth').value = data.settings.min_width || 0;
|
|
document.getElementById('minHeight').value = data.settings.min_height || 0;
|
|
document.getElementById('skipTransparent').checked = data.settings.skip_transparent || false;
|
|
document.getElementById('transparencyThreshold').value = (data.settings.transparency_threshold || 0.9) * 100;
|
|
document.getElementById('phashThreshold').value = data.settings.phash_threshold ?? 2;
|
|
toggleTransparencyThreshold();
|
|
}
|
|
});
|
|
|
|
// Load import stats
|
|
fetch('/api/import-stats')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.ok) {
|
|
document.getElementById('statProcessed').textContent = data.stats.total_processed || 0;
|
|
document.getElementById('statImported').textContent = data.stats.total_imported || 0;
|
|
document.getElementById('statDimension').textContent = data.stats.filtered_by_dimension || 0;
|
|
document.getElementById('statTransparency').textContent = data.stats.filtered_by_transparency || 0;
|
|
}
|
|
});
|
|
|
|
// Load tags for deletion dropdown
|
|
loadTagsForDeletion();
|
|
|
|
// Toggle transparency threshold visibility
|
|
document.getElementById('skipTransparent').addEventListener('change', toggleTransparencyThreshold);
|
|
|
|
function toggleTransparencyThreshold() {
|
|
const show = document.getElementById('skipTransparent').checked;
|
|
document.getElementById('transparencyThresholdGroup').style.display = show ? 'block' : 'none';
|
|
}
|
|
|
|
// Save import settings
|
|
document.getElementById('importSettingsForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData();
|
|
fd.append('min_width', document.getElementById('minWidth').value);
|
|
fd.append('min_height', document.getElementById('minHeight').value);
|
|
fd.append('skip_transparent', document.getElementById('skipTransparent').checked ? 'true' : 'false');
|
|
fd.append('transparency_threshold', document.getElementById('transparencyThreshold').value / 100);
|
|
fd.append('phash_threshold', document.getElementById('phashThreshold').value);
|
|
|
|
const r = await fetch('/api/import-settings', { method: 'POST', body: fd });
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
alert('Settings saved successfully!');
|
|
} else {
|
|
alert('Failed to save settings: ' + (data.error || 'Unknown error'));
|
|
}
|
|
});
|
|
|
|
// Tag selection for deletion
|
|
let selectedTagName = '';
|
|
document.getElementById('deleteTagSelect').addEventListener('change', async (e) => {
|
|
const tagId = e.target.value;
|
|
const previewEl = document.getElementById('deletePreview');
|
|
const countEl = document.getElementById('deleteCount');
|
|
const confirmGroup = document.getElementById('confirmationGroup');
|
|
const confirmInput = document.getElementById('deleteConfirmInput');
|
|
const confirmTagName = document.getElementById('confirmTagName');
|
|
const btn = document.getElementById('deleteByTagBtn');
|
|
|
|
// Reset state
|
|
confirmInput.value = '';
|
|
btn.disabled = true;
|
|
selectedTagName = '';
|
|
|
|
if (!tagId) {
|
|
previewEl.style.display = 'none';
|
|
confirmGroup.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const r = await fetch(`/api/tag/${tagId}/image-count`);
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
countEl.textContent = data.count;
|
|
selectedTagName = data.tag_name;
|
|
confirmTagName.textContent = selectedTagName;
|
|
previewEl.style.display = 'block';
|
|
confirmGroup.style.display = data.count > 0 ? 'block' : 'none';
|
|
}
|
|
});
|
|
|
|
// Enable delete button only when confirmation matches
|
|
document.getElementById('deleteConfirmInput').addEventListener('input', (e) => {
|
|
const btn = document.getElementById('deleteByTagBtn');
|
|
const typed = e.target.value.trim();
|
|
btn.disabled = typed !== selectedTagName;
|
|
});
|
|
|
|
// Delete by tag form
|
|
document.getElementById('deleteByTagForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const tagId = document.getElementById('deleteTagSelect').value;
|
|
const deleteTag = document.getElementById('deleteTagAlso').checked;
|
|
const count = document.getElementById('deleteCount').textContent;
|
|
const confirmInput = document.getElementById('deleteConfirmInput');
|
|
|
|
// Double-check confirmation matches (defense in depth)
|
|
if (confirmInput.value.trim() !== selectedTagName) {
|
|
alert('Tag name confirmation does not match.');
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData();
|
|
fd.append('tag_id', tagId);
|
|
fd.append('tag_name', selectedTagName); // Server validates this matches the ID
|
|
fd.append('delete_tag', deleteTag ? 'true' : 'false');
|
|
|
|
const r = await fetch('/api/delete-by-tag', { method: 'POST', body: fd });
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
alert(`Successfully deleted ${data.deleted_count} images.${data.tag_deleted ? ' Tag was also deleted.' : ''}`);
|
|
loadTagsForDeletion();
|
|
document.getElementById('deletePreview').style.display = 'none';
|
|
document.getElementById('confirmationGroup').style.display = 'none';
|
|
document.getElementById('deleteConfirmInput').value = '';
|
|
document.getElementById('deleteByTagBtn').disabled = true;
|
|
selectedTagName = '';
|
|
} else {
|
|
alert('Failed to delete images: ' + (data.error || 'Unknown error'));
|
|
}
|
|
});
|
|
|
|
async function loadTagsForDeletion() {
|
|
const select = document.getElementById('deleteTagSelect');
|
|
select.innerHTML = '<option value="">-- Select a tag --</option>';
|
|
|
|
// Get all tags (with IDs) from the API
|
|
const r = await fetch('/api/tags/search?limit=500');
|
|
const data = await r.json();
|
|
if (data.tags) {
|
|
// Group by kind
|
|
const grouped = {};
|
|
data.tags.forEach(t => {
|
|
const kind = t.kind || 'user';
|
|
if (!grouped[kind]) grouped[kind] = [];
|
|
grouped[kind].push(t);
|
|
});
|
|
|
|
// Add to select with optgroups
|
|
Object.keys(grouped).sort().forEach(kind => {
|
|
const optgroup = document.createElement('optgroup');
|
|
optgroup.label = kind.charAt(0).toUpperCase() + kind.slice(1) + ' Tags';
|
|
grouped[kind].sort((a, b) => a.name.localeCompare(b.name)).forEach(tag => {
|
|
const opt = document.createElement('option');
|
|
opt.value = tag.id; // Use tag ID as value
|
|
opt.textContent = tag.name;
|
|
optgroup.appendChild(opt);
|
|
});
|
|
select.appendChild(optgroup);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|