Changelog 0.6.0
This commit is contained in:
626
pkg/web/templates/config.html
Normal file
626
pkg/web/templates/config.html
Normal file
@@ -0,0 +1,626 @@
|
||||
{{ define "config" }}
|
||||
<div class="container mt-4">
|
||||
<form id="configForm">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">General Configuration</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="log-level">Log Level</label>
|
||||
<select class="form-select" name="log_level" id="log-level">
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="trace">Trace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Register Magnet Link Button -->
|
||||
<div class="col-md-6">
|
||||
<label>
|
||||
<!-- Empty label to keep the button aligned -->
|
||||
</label>
|
||||
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
|
||||
Open Magnet Links in Decypharr
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="discordWebhookUrl">Discord Webhook URL</label>
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="discordWebhookUrl"
|
||||
name="discord_webhook_url"
|
||||
placeholder="https://discord..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="allowedExtensions">Allowed File Extensions</label>
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="allowedExtensions"
|
||||
name="allowed_file_types"
|
||||
placeholder="mkv, mp4, avi, etc.">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="minFileSize">Minimum File Size</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="minFileSize"
|
||||
name="min_file_size"
|
||||
placeholder="e.g., 10MB, 1GB">
|
||||
<small class="form-text text-muted">Minimum file size to download (0 for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="maxFileSize">Maximum File Size</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="maxFileSize"
|
||||
name="max_file_size"
|
||||
placeholder="e.g., 50GB, 100MB">
|
||||
<small class="form-text text-muted">Maximum file size to download (0 for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Debrid Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Debrids</h5>
|
||||
<div id="debridConfigs"></div>
|
||||
<div class="mb-3">
|
||||
<button type="button" id="addDebridBtn" class="btn btn-secondary">
|
||||
<i class="bi bi-plus"></i> Add New Debrid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QBitTorrent Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">QBitTorrent</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="qbit.username">Username</label>
|
||||
<input type="text" class="form-control" name="qbit.username" id="qbit.username">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="qbit.password">Password</label>
|
||||
<input type="password" class="form-control" name="qbit.password" id="qbit.password">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="qbit.port">Port</label>
|
||||
<input type="text" class="form-control" name="qbit.port" id="qbit.port" placeholder="e.g., 8080">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
|
||||
<input type="text" class="form-control" name="qbit.download_folder" id="qbit.download_folder">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="qbit.refresh_interval">Refresh Interval (seconds)</label>
|
||||
<input type="number" class="form-control" name="qbit.refresh_interval" id="qbit.refresh_interval">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache" id="qbit.skip_pre_cache">
|
||||
<label class="form-check-label" for="qbit.skip_pre_cache">Skip Pre-Cache On Download(This caches a tiny part of your file to speed up import)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arr Configurations -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Arr Configurations</h5>
|
||||
<div id="arrConfigs"></div>
|
||||
<div class="mb-3">
|
||||
<button type="button" id="addArrBtn" class="btn btn-secondary">
|
||||
<i class="bi bi-plus"></i> Add New Arr
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repair Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Repair Configuration</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Interval</label>
|
||||
<input type="text" class="form-control" name="repair.interval" placeholder="e.g., 24h">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" >Zurg URL</label>
|
||||
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url" placeholder="http://zurg:9999">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="repair.enabled" id="repair.enabled">
|
||||
<label class="form-check-label" for="repair.enabled">Enable Repair</label>
|
||||
</div>
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="repair.use_webdav" id="repair.use_webdav">
|
||||
<label class="form-check-label" for="repair.use_webdav">Use Webdav</label>
|
||||
</div>
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="repair.run_on_start" id="repair.run_on_start">
|
||||
<label class="form-check-label" for="repair.run_on_start">Run on Start</label>
|
||||
</div>
|
||||
<div class="form-check d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="repair.auto_process" id="repair.auto_process">
|
||||
<label class="form-check-label" for="repair.auto_process">Auto Process(Scheduled jobs will be processed automatically)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end mt-4 mb-3">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
// Templates for dynamic elements
|
||||
const debridTemplate = (index) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
|
||||
onclick="if(confirm('Are you sure you want to delete this debrid?')) this.closest('.config-item').remove();"
|
||||
title="Delete this debrid">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].name" >Name</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].name" id="debrid[${index}].name" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
|
||||
<input type="password" class="form-control" name="debrid[${index}].api_key" id="debrid[${index}].api_key" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].folder" >Mount Folder</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].folder" id="debrid[${index}].folder">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rate_limit" >Rate Limit</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].rate_limit" id="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="debrid[${index}].download_uncached" id="debrid[${index}].download_uncached">
|
||||
<label class="form-check-label" for="debrid[${index}].download_uncached" >Download Uncached</label>
|
||||
</div>
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="debrid[${index}].check_cached" id="debrid[${index}].check_cached">
|
||||
<label class="form-check-label" for="debrid[${index}].check_cached" >Check Cached</label>
|
||||
</div>
|
||||
<div class="form-check d-inline-block">
|
||||
<input type="checkbox" class="form-check-input useWebdav" name="debrid[${index}].use_webdav" id="debrid[${index}].use_webdav">
|
||||
<label class="form-check-label" for="debrid[${index}].use_webdav" >Use WebDav</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 webdav d-none">
|
||||
<h6 class="pb-2">Webdav</h6>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].torrents_refresh_interval">Torrents Refresh Interval</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].torrents_refresh_interval" id="debrid[${index}].torrents_refresh_interval" placeholder="15s" required>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].download_links_refresh_interval">Download Links Refresh Interval</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].download_links_refresh_interval" id="debrid[${index}].download_links_refresh_interval" placeholder="24h" required>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].auto_expire_links_after">Expire Links After</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].auto_expire_links_after" id="debrid[${index}].auto_expire_links_after" placeholder="24h" required>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].folder_naming">Folder Naming Structure</label>
|
||||
<select class="form-select" name="debrid[${index}].folder_naming" id="debrid[${index}].folder_naming">
|
||||
<option value="filename">File name</option>
|
||||
<option value="filename_no_ext">File name with No Ext</option>
|
||||
<option value="original">Original name</option>
|
||||
<option value="original_no_ext">Original name with No Ext</option>
|
||||
<option value="id">Use ID</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].workers">Number of Workers</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].workers" id="debrid[${index}].workers" required placeholder="e.g., 20">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rc_url">Rclone RC URL</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].rc_url" id="debrid[${index}].rc_url" placeholder="e.g., http://localhost:9990">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rc_user">Rclone RC User</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].rc_user" id="debrid[${index}].rc_user">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
|
||||
<input type="password" class="form-control" name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const arrTemplate = (index) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
|
||||
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
|
||||
title="Delete this arr">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="arr[${index}].name" id="arr[${index}].name" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].host" class="form-label">Host</label>
|
||||
<input type="text" class="form-control" name="arr[${index}].host" id="arr[${index}].host" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for"arr[${index}].token" class="form-label">API Token</label>
|
||||
<input type="password" class="form-control" name="arr[${index}].token" id="arr[${index}].token" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].cleanup" class="form-check-label">Cleanup Queue</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].cleanup" id="arr[${index}].cleanup">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].skip_repair" class="form-check-label">Skip Repair</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].skip_repair" id="arr[${index}].skip_repair">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].download_uncached" class="form-check-label">Download Uncached</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].download_uncached" id="arr[${index}].download_uncached">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Main functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let debridCount = 0;
|
||||
let arrCount = 0;
|
||||
|
||||
// Load existing configuration
|
||||
fetch('/internal/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
// Load Debrid configs
|
||||
config.debrids?.forEach(debrid => {
|
||||
addDebridConfig(debrid);
|
||||
});
|
||||
|
||||
// Load QBitTorrent config
|
||||
if (config.qbittorrent) {
|
||||
Object.entries(config.qbittorrent).forEach(([key, value]) => {
|
||||
const input = document.querySelector(`[name="qbit.${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load Arr configs
|
||||
config.arrs?.forEach(arr => {
|
||||
addArrConfig(arr);
|
||||
});
|
||||
|
||||
// Load Repair config
|
||||
if (config.repair) {
|
||||
Object.entries(config.repair).forEach(([key, value]) => {
|
||||
const input = document.querySelector(`[name="repair.${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load general config
|
||||
|
||||
const logLevel = document.getElementById('log-level');
|
||||
logLevel.value = config.log_level;
|
||||
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
|
||||
document.querySelector('[name="allowed_file_types"]').value = config.allowed_file_types.join(', ');
|
||||
}
|
||||
if (config.min_file_size) {
|
||||
document.querySelector('[name="min_file_size"]').value = config.min_file_size;
|
||||
}
|
||||
if (config.max_file_size) {
|
||||
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
|
||||
}
|
||||
if (config.discord_webhook_url) {
|
||||
document.querySelector('[name="discord_webhook_url"]').value = config.discord_webhook_url;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading configuration:', error);
|
||||
createToast(`Error loading configuration: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveConfig(e);
|
||||
});
|
||||
|
||||
document.getElementById('addDebridBtn').addEventListener('click', () => {
|
||||
addDebridConfig();
|
||||
});
|
||||
document.getElementById('addArrBtn').addEventListener('click', () => {
|
||||
addArrConfig();
|
||||
});
|
||||
|
||||
$(document).on('change', '.useWebdav', function() {
|
||||
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
||||
if (webdavConfig.length === 0) return;
|
||||
if (this.checked) {
|
||||
webdavConfig.removeClass('d-none');
|
||||
} else {
|
||||
webdavConfig.addClass('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// In your JavaScript for the config page:
|
||||
async function saveConfig(e) {
|
||||
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Saving...';
|
||||
// Show a spinner or loading overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||
overlay.style.zIndex = '9999';
|
||||
overlay.innerHTML = `
|
||||
<div class="card p-4 text-center">
|
||||
<div class="spinner-border mb-3" role="status"></div>
|
||||
<h5>Applying configuration changes...</h5>
|
||||
<p class="text-muted">This may take a few seconds</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
try {
|
||||
const config = collectFormData();
|
||||
// Save config logic
|
||||
const response = await fetch('/internal/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
createToast('Configuration saved successfully! Services are restarting...', 'success');
|
||||
|
||||
// Wait a moment before reloading to allow the server to restart
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
createToast(`Error saving configuration: ${error.message}`, 'error');
|
||||
console.error('Error saving configuration:', error);
|
||||
overlay.remove(); // Remove overlay on error
|
||||
} finally {
|
||||
// Re-enable the button
|
||||
submitButton.disabled = false;
|
||||
submitButton.innerHTML = '<i class="bi bi-save"></i> Save';
|
||||
}
|
||||
}
|
||||
|
||||
function addDebridConfig(data = {}) {
|
||||
const container = document.getElementById('debridConfigs');
|
||||
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
|
||||
|
||||
// Add a delete button to the new debrid
|
||||
const newDebrid = container.lastElementChild;
|
||||
addDeleteButton(newDebrid, `Delete this debrid`);
|
||||
|
||||
if (data) {
|
||||
|
||||
if (data.use_webdav) {
|
||||
let _webCfg = newDebrid.querySelector(`.webdav`);
|
||||
if (_webCfg) {
|
||||
_webCfg.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debridCount++;
|
||||
}
|
||||
|
||||
function addArrConfig(data = {}) {
|
||||
const container = document.getElementById('arrConfigs');
|
||||
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
|
||||
|
||||
// Add a delete button to the new arr
|
||||
const newArr = container.lastElementChild;
|
||||
addDeleteButton(newArr, `Delete this arr`);
|
||||
|
||||
if (data) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const input = container.querySelector(`[name="arr[${arrCount}].${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
arrCount++;
|
||||
}
|
||||
|
||||
function addDeleteButton(element, tooltip) {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn btn-sm btn-danger position-absolute top-0 end-0 m-2';
|
||||
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
deleteBtn.title = tooltip;
|
||||
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to delete this item?')) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
element.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
function collectFormData() {
|
||||
const formEl = document.getElementById('configForm');
|
||||
|
||||
// Create the config object
|
||||
const config = {
|
||||
log_level: document.getElementById('log-level').value,
|
||||
discord_webhook_url: document.getElementById('discordWebhookUrl').value,
|
||||
allowed_file_types: document.getElementById('allowedExtensions').value.split(',').map(ext => ext.trim()).filter(Boolean),
|
||||
min_file_size: document.getElementById('minFileSize').value,
|
||||
max_file_size: document.getElementById('maxFileSize').value,
|
||||
debrids: [],
|
||||
qbittorrent: {
|
||||
username: document.querySelector('[name="qbit.username"]').value,
|
||||
password: document.querySelector('[name="qbit.password"]').value,
|
||||
port: document.querySelector('[name="qbit.port"]').value,
|
||||
download_folder: document.querySelector('[name="qbit.download_folder"]').value,
|
||||
refresh_interval: parseInt(document.querySelector('[name="qbit.refresh_interval"]').value || '0', 10)
|
||||
},
|
||||
arrs: [],
|
||||
repair: {
|
||||
enabled: document.querySelector('[name="repair.enabled"]').checked,
|
||||
interval: document.querySelector('[name="repair.interval"]').value,
|
||||
run_on_start: document.querySelector('[name="repair.run_on_start"]').checked,
|
||||
zurg_url: document.querySelector('[name="repair.zurg_url"]').value,
|
||||
use_webdav: document.querySelector('[name="repair.use_webdav"]').checked,
|
||||
auto_process: document.querySelector('[name="repair.auto_process"]').checked
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all debrids
|
||||
for (let i = 0; i < debridCount; i++) {
|
||||
const nameEl = document.querySelector(`[name="debrid[${i}].name"]`);
|
||||
if (!nameEl) continue;
|
||||
|
||||
const debrid = {
|
||||
name: nameEl.value,
|
||||
api_key: document.querySelector(`[name="debrid[${i}].api_key"]`).value,
|
||||
folder: document.querySelector(`[name="debrid[${i}].folder"]`).value,
|
||||
rate_limit: document.querySelector(`[name="debrid[${i}].rate_limit"]`).value,
|
||||
download_uncached: document.querySelector(`[name="debrid[${i}].download_uncached"]`).checked,
|
||||
check_cached: document.querySelector(`[name="debrid[${i}].check_cached"]`).checked,
|
||||
use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).checked,
|
||||
torrents_refresh_interval: document.querySelector(`[name="debrid[${i}].torrents_refresh_interval"]`).value,
|
||||
download_links_refresh_interval: document.querySelector(`[name="debrid[${i}].download_links_refresh_interval"]`).value,
|
||||
auto_expire_links_after: document.querySelector(`[name="debrid[${i}].auto_expire_links_after"]`).value,
|
||||
folder_naming: document.querySelector(`[name="debrid[${i}].folder_naming"]`).value,
|
||||
workers: parseInt(document.querySelector(`[name="debrid[${i}].workers"]`).value),
|
||||
rc_url: document.querySelector(`[name="debrid[${i}].rc_url"]`).value,
|
||||
rc_user: document.querySelector(`[name="debrid[${i}].rc_user"]`).value,
|
||||
rc_pass: document.querySelector(`[name="debrid[${i}].rc_pass"]`).value
|
||||
};
|
||||
|
||||
if (debrid.name && debrid.api_key) {
|
||||
config.debrids.push(debrid);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all arrs
|
||||
for (let i = 0; i < arrCount; i++) {
|
||||
const nameEl = document.querySelector(`[name="arr[${i}].name"]`);
|
||||
if (!nameEl) continue;
|
||||
|
||||
const arr = {
|
||||
name: nameEl.value,
|
||||
host: document.querySelector(`[name="arr[${i}].host"]`).value,
|
||||
token: document.querySelector(`[name="arr[${i}].token"]`).value,
|
||||
cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked,
|
||||
skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked,
|
||||
download_uncached: document.querySelector(`[name="arr[${i}].download_uncached"]`).checked
|
||||
};
|
||||
|
||||
if (arr.name && arr.host) {
|
||||
config.arrs.push(arr);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
});
|
||||
// Register magnet link handler
|
||||
function registerMagnetLinkHandler() {
|
||||
if ('registerProtocolHandler' in navigator) {
|
||||
try {
|
||||
navigator.registerProtocolHandler(
|
||||
'magnet',
|
||||
`${window.location.origin}/download?magnet=%s`,
|
||||
'DecyphArr'
|
||||
);
|
||||
localStorage.setItem('magnetHandler', 'true');
|
||||
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||
console.log('Registered magnet link handler successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to register magnet link handler:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var magnetHandler = localStorage.getItem('magnetHandler');
|
||||
if (magnetHandler === 'true') {
|
||||
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
154
pkg/web/templates/download.html
Normal file
154
pkg/web/templates/download.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{{ define "download" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="downloadForm" enctype="multipart/form-data">
|
||||
<div class="mb-2">
|
||||
<label for="magnetURI" class="form-label">Torrent(s)</label>
|
||||
<textarea class="form-control" id="magnetURI" name="urls" rows="8" placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="file" class="form-control" id="torrentFiles" name="torrents" multiple accept=".torrent,.magnet">
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Enter Category</label>
|
||||
<input type="text" class="form-control" id="category" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check d-inline-block me-3">
|
||||
<input type="checkbox" class="form-check-input" id="isSymlink" name="notSymlink">
|
||||
<label class="form-check-label" for="isSymlink">No Symlinks</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="downloadUncached" id="downloadUncached">
|
||||
<label class="form-check-label" for="downloadUncached">Download Uncached</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitDownload">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loadSavedDownloadOptions = () => {
|
||||
const savedCategory = localStorage.getItem('downloadCategory');
|
||||
const savedSymlink = localStorage.getItem('downloadSymlink');
|
||||
const savedDownloadUncached = localStorage.getItem('downloadUncached');
|
||||
document.getElementById('category').value = savedCategory || '';
|
||||
document.getElementById('isSymlink').checked = savedSymlink === 'true';
|
||||
document.getElementById('downloadUncached').checked = savedDownloadUncached === 'true';
|
||||
};
|
||||
|
||||
const saveCurrentDownloadOptions = () => {
|
||||
const category = document.getElementById('category').value;
|
||||
const isSymlink = document.getElementById('isSymlink').checked;
|
||||
const downloadUncached = document.getElementById('downloadUncached').checked;
|
||||
localStorage.setItem('downloadCategory', category);
|
||||
localStorage.setItem('downloadSymlink', isSymlink.toString());
|
||||
localStorage.setItem('downloadUncached', downloadUncached.toString());
|
||||
};
|
||||
|
||||
// Load the last used download options from local storage
|
||||
loadSavedDownloadOptions();
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('submitDownload');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add URLs if present
|
||||
const urls = document.getElementById('magnetURI').value
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
|
||||
if (urls.length > 0) {
|
||||
formData.append('urls', urls.join('\n'));
|
||||
}
|
||||
|
||||
// Add torrent files if present
|
||||
const fileInput = document.getElementById('torrentFiles');
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
formData.append('files', fileInput.files[i]);
|
||||
}
|
||||
|
||||
if (urls.length + fileInput.files.length === 0) {
|
||||
createToast('Please submit at least one torrent', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length + fileInput.files.length > 100) {
|
||||
createToast('Please submit up to 100 torrents at a time', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
formData.append('arr', document.getElementById('category').value);
|
||||
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
||||
|
||||
const response = await fetch('/internal/add', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Unknown error');
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
if (result.results.length > 0) {
|
||||
createToast(`Added ${result.results.length} torrents with ${result.errors.length} errors:\n${result.errors.join('\n')}`, 'warning');
|
||||
} else {
|
||||
createToast(`Failed to add torrents:\n${result.errors.join('\n')}`, 'error');
|
||||
}
|
||||
} else {
|
||||
createToast(`Successfully added ${result.results.length} torrents!`);
|
||||
document.getElementById('magnetURI').value = '';
|
||||
document.getElementById('torrentFiles').value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
createToast(`Error adding downloads: ${error.message}`, 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Save the download options to local storage when they change
|
||||
document.getElementById('category').addEventListener('change', saveCurrentDownloadOptions);
|
||||
document.getElementById('isSymlink').addEventListener('change', saveCurrentDownloadOptions);
|
||||
|
||||
// Read the URL parameters for a magnet link and add it to the download queue if found
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const magnetURI = urlParams.get('magnet');
|
||||
if (magnetURI) {
|
||||
document.getElementById('magnetURI').value = magnetURI;
|
||||
history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
421
pkg/web/templates/index.html
Normal file
421
pkg/web/templates/index.html
Normal file
@@ -0,0 +1,421 @@
|
||||
{{ define "index" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-4">
|
||||
<h4 class="mb-0 text-nowrap"><i class="bi bi-table me-2"></i>Active Torrents</h4>
|
||||
<div class="d-flex align-items-center overflow-auto" style="flex-wrap: nowrap; gap: 0.5rem;">
|
||||
<button class="btn btn-outline-danger btn-sm" id="batchDeleteBtn" style="display: none; flex-shrink: 0;">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn" style="flex-shrink: 0;">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
|
||||
<option value="">All States</option>
|
||||
<option value="pausedup">Completed</option>
|
||||
<option value="downloading">Downloading</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm d-inline-block w-auto" id="sortSelector" style="flex-shrink: 0;">
|
||||
<option value="added_on" selected>Date Added (Newest First)</option>
|
||||
<option value="added_on_asc">Date Added (Oldest First)</option>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
<option value="name_desc">Name (Z-A)</option>
|
||||
<option value="size_desc">Size (Largest First)</option>
|
||||
<option value="size_asc">Size (Smallest First)</option>
|
||||
<option value="progress_desc">Progress (Most First)</option>
|
||||
<option value="progress_asc">Progress (Least First)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" class="form-check-input" id="selectAll">
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Progress</th>
|
||||
<th>Speed</th>
|
||||
<th>Category</th>
|
||||
<th>Debrid</th>
|
||||
<th>State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="torrentsList">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center p-3 border-top">
|
||||
<div class="pagination-info">
|
||||
<span id="paginationInfo">Showing 0-0 of 0 torrents</span>
|
||||
</div>
|
||||
<nav aria-label="Torrents pagination">
|
||||
<ul class="pagination pagination-sm m-0" id="paginationControls"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let refs = {
|
||||
torrentsList: document.getElementById('torrentsList'),
|
||||
categoryFilter: document.getElementById('categoryFilter'),
|
||||
stateFilter: document.getElementById('stateFilter'),
|
||||
sortSelector: document.getElementById('sortSelector'),
|
||||
selectAll: document.getElementById('selectAll'),
|
||||
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
paginationControls: document.getElementById('paginationControls'),
|
||||
paginationInfo: document.getElementById('paginationInfo')
|
||||
};
|
||||
let state = {
|
||||
torrents: [],
|
||||
selectedTorrents: new Set(),
|
||||
categories: new Set(),
|
||||
states: new Set('downloading', 'pausedup', 'error'),
|
||||
selectedCategory: refs.categoryFilter?.value || '',
|
||||
selectedState: refs.stateFilter?.value || '',
|
||||
sortBy: refs.sortSelector?.value || 'added_on',
|
||||
itemsPerPage: 20,
|
||||
currentPage: 1
|
||||
};
|
||||
|
||||
const torrentRowTemplate = (torrent) => `
|
||||
<tr data-hash="${torrent.hash}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input torrent-select" data-hash="${torrent.hash}" ${state.selectedTorrents.has(torrent.hash) ? 'checked' : ''}>
|
||||
</td>
|
||||
<td class="text-nowrap text-truncate overflow-hidden" style="max-width: 350px;" title="${torrent.name}">${torrent.name}</td>
|
||||
<td class="text-nowrap">${formatBytes(torrent.size)}</td>
|
||||
<td style="min-width: 150px;">
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: ${(torrent.progress * 100).toFixed(1)}%"
|
||||
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
|
||||
</td>
|
||||
<td>${formatSpeed(torrent.dlspeed)}</td>
|
||||
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
||||
<td>${torrent.debrid || 'None'}</td>
|
||||
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}', false)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
${torrent.debrid && torrent.id ? `
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}', true)">
|
||||
<i class="bi bi-trash"></i> Remove from Debrid
|
||||
</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
return `${formatBytes(speed)}/s`;
|
||||
}
|
||||
|
||||
function getStateColor(state) {
|
||||
const stateColors = {
|
||||
'downloading': 'bg-primary',
|
||||
'pausedup': 'bg-success',
|
||||
'error': 'bg-danger',
|
||||
};
|
||||
return stateColors[state?.toLowerCase()] || 'bg-secondary';
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
// Filter torrents by selected category and state
|
||||
let filteredTorrents = state.torrents;
|
||||
if (state.selectedCategory) {
|
||||
filteredTorrents = filteredTorrents.filter(t => t.category === state.selectedCategory);
|
||||
}
|
||||
if (state.selectedState) {
|
||||
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
|
||||
}
|
||||
|
||||
// Sort the filtered torrents
|
||||
filteredTorrents = sortTorrents(filteredTorrents, state.sortBy);
|
||||
|
||||
const totalPages = Math.ceil(filteredTorrents.length / state.itemsPerPage);
|
||||
if (state.currentPage > totalPages && totalPages > 0) {
|
||||
state.currentPage = totalPages;
|
||||
}
|
||||
|
||||
const paginatedTorrents = paginateTorrents(filteredTorrents);
|
||||
|
||||
// Update the torrents list table
|
||||
refs.torrentsList.innerHTML = paginatedTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
||||
|
||||
|
||||
// Update the category filter dropdown
|
||||
const currentCategories = Array.from(state.categories).sort();
|
||||
const categoryOptions = ['<option value="">All Categories</option>']
|
||||
.concat(currentCategories.map(cat =>
|
||||
`<option value="${cat}" ${cat === state.selectedCategory ? 'selected' : ''}>${cat}</option>`
|
||||
));
|
||||
refs.categoryFilter.innerHTML = categoryOptions.join('');
|
||||
|
||||
// Clean up selected torrents that no longer exist
|
||||
state.selectedTorrents = new Set(
|
||||
Array.from(state.selectedTorrents)
|
||||
.filter(hash => filteredTorrents.some(t => t.hash === hash))
|
||||
);
|
||||
|
||||
// Update batch delete button visibility
|
||||
refs.batchDeleteBtn.style.display = state.selectedTorrents.size > 0 ? '' : 'none';
|
||||
|
||||
// Update the select all checkbox state
|
||||
refs.selectAll.checked = filteredTorrents.length > 0 && filteredTorrents.every(torrent => state.selectedTorrents.has(torrent.hash));
|
||||
}
|
||||
|
||||
async function loadTorrents() {
|
||||
try {
|
||||
const response = await fetch('/internal/torrents');
|
||||
const torrents = await response.json();
|
||||
|
||||
state.torrents = torrents;
|
||||
state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
|
||||
|
||||
updateUI();
|
||||
} catch (error) {
|
||||
console.error('Error loading torrents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function sortTorrents(torrents, sortBy) {
|
||||
// Create a copy of the array to avoid mutating the original
|
||||
const result = [...torrents];
|
||||
|
||||
// Parse the sort value to determine field and direction
|
||||
const [field, direction] = sortBy.includes('_asc') || sortBy.includes('_desc')
|
||||
? [sortBy.split('_').slice(0, -1).join('_'), sortBy.endsWith('_asc') ? 'asc' : 'desc']
|
||||
: [sortBy, 'desc']; // Default to descending if not specified
|
||||
|
||||
result.sort((a, b) => {
|
||||
let valueA, valueB;
|
||||
|
||||
// Get values based on field
|
||||
switch (field) {
|
||||
case 'name':
|
||||
valueA = a.name?.toLowerCase() || '';
|
||||
valueB = b.name?.toLowerCase() || '';
|
||||
break;
|
||||
case 'size':
|
||||
valueA = a.size || 0;
|
||||
valueB = b.size || 0;
|
||||
break;
|
||||
case 'progress':
|
||||
valueA = a.progress || 0;
|
||||
valueB = b.progress || 0;
|
||||
break;
|
||||
case 'added_on':
|
||||
valueA = a.added_on || 0;
|
||||
valueB = b.added_on || 0;
|
||||
break;
|
||||
default:
|
||||
valueA = a[field] || 0;
|
||||
valueB = b[field] || 0;
|
||||
}
|
||||
|
||||
// Compare based on type
|
||||
if (typeof valueA === 'string') {
|
||||
return direction === 'asc'
|
||||
? valueA.localeCompare(valueB)
|
||||
: valueB.localeCompare(valueA);
|
||||
} else {
|
||||
return direction === 'asc'
|
||||
? valueA - valueB
|
||||
: valueB - valueA;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function deleteTorrent(hash, category, removeFromDebrid = false) {
|
||||
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/internal/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
createToast('Torrent deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error deleting torrent:', error);
|
||||
createToast('Failed to delete torrent', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedTorrents() {
|
||||
if (!confirm(`Are you sure you want to delete ${state.selectedTorrents.size} selected torrents?`)) return;
|
||||
|
||||
try {
|
||||
// COmma separated list of hashes
|
||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
||||
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
createToast('Selected torrents deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error deleting torrents:', error);
|
||||
createToast('Failed to delete some torrents' , 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function paginateTorrents(torrents) {
|
||||
const totalItems = torrents.length;
|
||||
const totalPages = Math.ceil(totalItems / state.itemsPerPage);
|
||||
const startIndex = (state.currentPage - 1) * state.itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + state.itemsPerPage, totalItems);
|
||||
|
||||
// Update pagination info text
|
||||
refs.paginationInfo.textContent =
|
||||
`Showing ${totalItems > 0 ? startIndex + 1 : 0}-${endIndex} of ${totalItems} torrents`;
|
||||
|
||||
// Generate pagination controls
|
||||
refs.paginationControls.innerHTML = '';
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return torrents.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${state.currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `
|
||||
<a class="page-link" href="#" aria-label="Previous" ${state.currentPage === 1 ? 'tabindex="-1" aria-disabled="true"' : ''}>
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
`;
|
||||
if (state.currentPage > 1) {
|
||||
prevLi.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
state.currentPage--;
|
||||
updateUI();
|
||||
});
|
||||
}
|
||||
refs.paginationControls.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
const maxPageButtons = 5;
|
||||
let startPage = Math.max(1, state.currentPage - Math.floor(maxPageButtons / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxPageButtons) {
|
||||
startPage = Math.max(1, endPage - maxPageButtons + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const pageLi = document.createElement('li');
|
||||
pageLi.className = `page-item ${i === state.currentPage ? 'active' : ''}`;
|
||||
pageLi.innerHTML = `<a class="page-link" href="#">${i}</a>`;
|
||||
|
||||
pageLi.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
state.currentPage = i;
|
||||
updateUI();
|
||||
});
|
||||
|
||||
refs.paginationControls.appendChild(pageLi);
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${state.currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `
|
||||
<a class="page-link" href="#" aria-label="Next" ${state.currentPage === totalPages ? 'tabindex="-1" aria-disabled="true"' : ''}>
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
`;
|
||||
if (state.currentPage < totalPages) {
|
||||
nextLi.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
state.currentPage++;
|
||||
updateUI();
|
||||
});
|
||||
}
|
||||
refs.paginationControls.appendChild(nextLi);
|
||||
|
||||
return torrents.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTorrents();
|
||||
const refreshInterval = setInterval(loadTorrents, 5000);
|
||||
|
||||
refs.refreshBtn.addEventListener('click', loadTorrents);
|
||||
refs.batchDeleteBtn.addEventListener('click', deleteSelectedTorrents);
|
||||
|
||||
refs.selectAll.addEventListener('change', (e) => {
|
||||
const filteredTorrents = state.torrents.filter(t => {
|
||||
if (state.selectedCategory && t.category !== state.selectedCategory) return false;
|
||||
if (state.selectedState && t.state?.toLowerCase() !== state.selectedState.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (e.target.checked) {
|
||||
filteredTorrents.forEach(torrent => state.selectedTorrents.add(torrent.hash));
|
||||
} else {
|
||||
filteredTorrents.forEach(torrent => state.selectedTorrents.delete(torrent.hash));
|
||||
}
|
||||
updateUI();
|
||||
});
|
||||
|
||||
refs.torrentsList.addEventListener('change', (e) => {
|
||||
if (e.target.classList.contains('torrent-select')) {
|
||||
const hash = e.target.dataset.hash;
|
||||
if (e.target.checked) {
|
||||
state.selectedTorrents.add(hash);
|
||||
} else {
|
||||
state.selectedTorrents.delete(hash);
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
refs.categoryFilter.addEventListener('change', (e) => {
|
||||
state.selectedCategory = e.target.value;
|
||||
state.currentPage = 1; // Reset to first page
|
||||
updateUI();
|
||||
});
|
||||
|
||||
refs.stateFilter.addEventListener('change', (e) => {
|
||||
state.selectedState = e.target.value;
|
||||
state.currentPage = 1; // Reset to first page
|
||||
updateUI();
|
||||
});
|
||||
|
||||
refs.sortSelector.addEventListener('change', (e) => {
|
||||
state.sortBy = e.target.value;
|
||||
state.currentPage = 1; // Reset to first page
|
||||
updateUI();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
330
pkg/web/templates/layout.html
Normal file
330
pkg/web/templates/layout.html
Normal file
@@ -0,0 +1,330 @@
|
||||
{{ define "layout" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DecyphArr - {{.Title}}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #1e40af;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333333;
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--primary-color: #3b82f6;
|
||||
--secondary-color: #60a5fa;
|
||||
--bg-color: #1e293b;
|
||||
--card-bg: #283548;
|
||||
--text-color: #e5e7eb;
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--border-color: #4b5563;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 1rem 0;
|
||||
background: var(--card-bg) !important;
|
||||
box-shadow: var(--nav-shadow);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge#channel-badge {
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
|
||||
.badge#channel-badge.beta {
|
||||
background-color: #fd7e14;
|
||||
}
|
||||
.badge#channel-badge.nightly {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Dark mode specific overrides */
|
||||
[data-bs-theme="dark"] .navbar-light .navbar-toggler-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .form-control,
|
||||
[data-bs-theme="dark"] .form-select {
|
||||
background-color: #374151;
|
||||
color: #e5e7eb;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .form-control:focus,
|
||||
[data-bs-theme="dark"] .form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Theme toggle button styles */
|
||||
.theme-toggle {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', savedTheme);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<!-- Toast messages will be created dynamically here -->
|
||||
</div>
|
||||
<nav class="navbar navbar-expand-lg navbar-light mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-cloud-download me-2"></i>DecyphArr
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
|
||||
<i class="bi bi-table me-1"></i>Torrents
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
|
||||
<i class="bi bi-cloud-download me-1"></i>Download
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
|
||||
<i class="bi bi-tools me-1"></i>Repair
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
|
||||
<i class="bi bi-gear me-1"></i>Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/webdav" target="_blank">
|
||||
<i class="bi bi-cloud me-1"></i>WebDAV
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/logs" target="_blank">
|
||||
<i class="bi bi-journal me-1"></i>Logs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="theme-toggle me-3" id="themeToggle" title="Toggle dark mode">
|
||||
<i class="bi bi-sun-fill" id="lightIcon"></i>
|
||||
<i class="bi bi-moon-fill d-none" id="darkIcon"></i>
|
||||
</div>
|
||||
<span class="badge me-2" id="channel-badge">Loading...</span>
|
||||
<span class="badge bg-primary" id="version-badge">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{ if eq .Page "index" }}
|
||||
{{ template "index" . }}
|
||||
{{ else if eq .Page "download" }}
|
||||
{{ template "download" . }}
|
||||
{{ else if eq .Page "repair" }}
|
||||
{{ template "repair" . }}
|
||||
{{ else if eq .Page "config" }}
|
||||
{{ template "config" . }}
|
||||
{{ else if eq .Page "login" }}
|
||||
{{ template "login" . }}
|
||||
{{ else if eq .Page "auth" }}
|
||||
{{ template "auth" . }}
|
||||
{{ else }}
|
||||
{{ end }}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
/**
|
||||
* Create a toast message
|
||||
* @param {string} message - The message to display
|
||||
* @param {string} [type='success'] - The type of toast (success, warning, error)
|
||||
*/
|
||||
const createToast = (message, type = 'success') => {
|
||||
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
|
||||
|
||||
const toastTimeouts = {
|
||||
success: 5000,
|
||||
warning: 10000,
|
||||
error: 15000
|
||||
};
|
||||
|
||||
const toastContainer = document.querySelector('.toast-container');
|
||||
const toastId = `toast-${Date.now()}`;
|
||||
|
||||
const toastHtml = `
|
||||
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
|
||||
<strong class="me-auto">
|
||||
${type === 'error' ? 'Error' : type === 'warning' ? 'Warning' : 'Success'}
|
||||
</strong>
|
||||
<button type="button" class="btn-close ${type === 'warning' ? '' : 'btn-close-white'}" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
${message.replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: toastTimeouts[type]
|
||||
});
|
||||
|
||||
toast.show();
|
||||
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.remove();
|
||||
});
|
||||
};
|
||||
|
||||
// Theme management
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const lightIcon = document.getElementById('lightIcon');
|
||||
const darkIcon = document.getElementById('darkIcon');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
// Function to set the theme
|
||||
function setTheme(theme) {
|
||||
htmlElement.setAttribute('data-bs-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
if (theme === 'dark') {
|
||||
lightIcon.classList.add('d-none');
|
||||
darkIcon.classList.remove('d-none');
|
||||
} else {
|
||||
lightIcon.classList.remove('d-none');
|
||||
darkIcon.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for saved theme preference or use system preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
} else {
|
||||
// Check for system preference
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme when button is clicked
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = htmlElement.getAttribute('data-bs-theme');
|
||||
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch('/internal/version')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const versionBadge = document.getElementById('version-badge');
|
||||
const channelBadge = document.getElementById('channel-badge');
|
||||
|
||||
// Add url to version badge
|
||||
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/debrid-blackhole/releases/tag/${data.version}" target="_blank" class="text-white">${data.version}</a>`;
|
||||
channelBadge.textContent = data.channel.charAt(0).toUpperCase() + data.channel.slice(1);
|
||||
|
||||
if (data.channel === 'beta') {
|
||||
channelBadge.classList.add('beta');
|
||||
} else if (data.channel === 'nightly') {
|
||||
channelBadge.classList.add('nightly');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching version:', error);
|
||||
document.getElementById('version-badge').textContent = 'Unknown';
|
||||
document.getElementById('channel-badge').textContent = 'Unknown';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
131
pkg/web/templates/login.html
Normal file
131
pkg/web/templates/login.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{{ define "login" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
createToast('Invalid credentials', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
createToast('Login failed', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "auth" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="authForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Choose Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Choose Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('authForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
createToast('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value,
|
||||
password: password,
|
||||
confirmPassword: confirmPassword
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const error = await response.text();
|
||||
createToast(error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
createToast('Setup failed', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
575
pkg/web/templates/repair.html
Normal file
575
pkg/web/templates/repair.html
Normal file
@@ -0,0 +1,575 @@
|
||||
{{ define "repair" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="repairForm">
|
||||
<div class="mb-3">
|
||||
<label for="arrSelect" class="form-label">Select Arr Instance</label>
|
||||
<select class="form-select" id="arrSelect">
|
||||
<option value="">Select an Arr instance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mediaIds" class="form-label">Media IDs</label>
|
||||
<input type="text" class="form-control" id="mediaIds"
|
||||
placeholder="Enter IDs (comma-separated)">
|
||||
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isAsync" checked>
|
||||
<label class="form-check-label" for="isAsync">
|
||||
Run in background
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="autoProcess">
|
||||
<label class="form-check-label" for="autoProcess">
|
||||
Auto Process(this will delete and re-search broken media)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitRepair">
|
||||
<i class="bi bi-wrench me-2"></i>Start Repair
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table Section -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i>Repair Jobs</h4>
|
||||
<div>
|
||||
<button id="deleteSelectedJobs" class="btn btn-sm btn-danger me-2" disabled>
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
<button id="refreshJobs" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover" id="jobsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="selectAllJobs">
|
||||
</div>
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>Arr Instances</th>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Broken Items</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody">
|
||||
<!-- Jobs will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Jobs pagination" class="mt-3">
|
||||
<ul class="pagination justify-content-center" id="jobsPagination">
|
||||
<!-- Pagination will be generated here -->
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div id="noJobsMessage" class="text-center py-3 d-none">
|
||||
<p class="text-muted">No repair jobs found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal -->
|
||||
<div class="modal fade" id="jobDetailsModal" tabindex="-1" aria-labelledby="jobDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="jobDetailsModalLabel">Job Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Job ID:</strong> <span id="modalJobId"></span></p>
|
||||
<p><strong>Status:</strong> <span id="modalJobStatus"></span></p>
|
||||
<p><strong>Started:</strong> <span id="modalJobStarted"></span></p>
|
||||
<p><strong>Completed:</strong> <span id="modalJobCompleted"></span></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Arrs:</strong> <span id="modalJobArrs"></span></p>
|
||||
<p><strong>Media IDs:</strong> <span id="modalJobMediaIds"></span></p>
|
||||
<p><strong>Auto Process:</strong> <span id="modalJobAutoProcess"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorContainer" class="alert alert-danger mb-3 d-none">
|
||||
<strong>Error:</strong> <span id="modalJobError"></span>
|
||||
</div>
|
||||
|
||||
<h6>Broken Items</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Arr</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="brokenItemsTableBody">
|
||||
<!-- Broken items will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="noBrokenItemsMessage" class="text-center py-2 d-none">
|
||||
<p class="text-muted">No broken items found</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load Arr instances
|
||||
fetch('/internal/arrs')
|
||||
.then(response => response.json())
|
||||
.then(arrs => {
|
||||
const select = document.getElementById('arrSelect');
|
||||
arrs.forEach(arr => {
|
||||
const option = document.createElement('option');
|
||||
option.value = arr.name;
|
||||
option.textContent = arr.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('repairForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('submitRepair');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
|
||||
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||
let arr = document.getElementById('arrSelect').value;
|
||||
try {
|
||||
const response = await fetch('/internal/repair', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
arr: arr,
|
||||
mediaIds: mediaIds,
|
||||
async: document.getElementById('isAsync').checked,
|
||||
autoProcess: document.getElementById('autoProcess').checked,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
createToast('Repair process initiated successfully!');
|
||||
loadJobs(1); // Refresh jobs after submission
|
||||
} catch (error) {
|
||||
createToast(`Error starting repair: ${error.message}`, 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Jobs table pagination variables
|
||||
let currentPage = 1;
|
||||
const itemsPerPage = 10;
|
||||
let allJobs = [];
|
||||
|
||||
// Load jobs function
|
||||
async function loadJobs(page) {
|
||||
try {
|
||||
const response = await fetch('/internal/repair/jobs');
|
||||
if (!response.ok) throw new Error('Failed to fetch jobs');
|
||||
|
||||
allJobs = await response.json();
|
||||
renderJobsTable(page);
|
||||
} catch (error) {
|
||||
console.error('Error loading jobs:', error);
|
||||
createToast(`Error loading jobs: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Render jobs table with pagination
|
||||
function renderJobsTable(page) {
|
||||
const tableBody = document.getElementById('jobsTableBody');
|
||||
const paginationElement = document.getElementById('jobsPagination');
|
||||
const noJobsMessage = document.getElementById('noJobsMessage');
|
||||
const deleteSelectedBtn = document.getElementById('deleteSelectedJobs');
|
||||
|
||||
// Clear previous content
|
||||
tableBody.innerHTML = '';
|
||||
paginationElement.innerHTML = '';
|
||||
|
||||
document.getElementById('selectAllJobs').checked = false;
|
||||
deleteSelectedBtn.disabled = true;
|
||||
|
||||
if (allJobs.length === 0) {
|
||||
noJobsMessage.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
noJobsMessage.classList.add('d-none');
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(allJobs.length / itemsPerPage);
|
||||
const startIndex = (page - 1) * itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + itemsPerPage, allJobs.length);
|
||||
|
||||
// Display jobs for current page
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const job = allJobs[i];
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Format date
|
||||
const startedDate = new Date(job.created_at);
|
||||
const formattedDate = startedDate.toLocaleString();
|
||||
|
||||
// Determine status
|
||||
let status = 'In Progress';
|
||||
let statusClass = 'text-primary';
|
||||
let canDelete = job.status !== "started";
|
||||
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
|
||||
|
||||
if (job.status === 'failed') {
|
||||
status = 'Failed';
|
||||
statusClass = 'text-danger';
|
||||
} else if (job.status === 'completed') {
|
||||
status = 'Completed';
|
||||
statusClass = 'text-success';
|
||||
} else if (job.status === 'pending') {
|
||||
status = 'Pending';
|
||||
statusClass = 'text-warning';
|
||||
} else if (job.status === "processing") {
|
||||
status = 'Processing';
|
||||
statusClass = 'text-info';
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input job-checkbox" type="checkbox" value="${job.id}"
|
||||
${canDelete ? '' : 'disabled'} data-can-delete="${canDelete}">
|
||||
</div>
|
||||
</td>
|
||||
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
|
||||
<td>${job.arrs.join(', ')}</td>
|
||||
<td><small>${formattedDate}</small></td>
|
||||
<td><span class="${statusClass}">${status}</span></td>
|
||||
<td>${totalItems}</td>
|
||||
<td>
|
||||
${job.status === "pending" ?
|
||||
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
|
||||
<i class="bi bi-play-fill"></i> Process
|
||||
</button>` :
|
||||
`<button class="btn btn-sm btn-primary" disabled>
|
||||
<i class="bi bi-eye"></i> Process
|
||||
</button>`
|
||||
}
|
||||
${canDelete ?
|
||||
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>` :
|
||||
`<button class="btn btn-sm btn-danger" disabled>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>`
|
||||
}
|
||||
</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
|
||||
// Create pagination
|
||||
if (totalPages > 1) {
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous" ${page !== 1 ? `data-page="${page - 1}"` : ''}>
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>`;
|
||||
paginationElement.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const pageLi = document.createElement('li');
|
||||
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
|
||||
pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
|
||||
paginationElement.appendChild(pageLi);
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${page === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next" ${page !== totalPages ? `data-page="${page + 1}"` : ''}>
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>`;
|
||||
paginationElement.appendChild(nextLi);
|
||||
}
|
||||
|
||||
// Add event listeners to pagination
|
||||
document.querySelectorAll('#jobsPagination a[data-page]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const newPage = parseInt(e.currentTarget.dataset.page);
|
||||
currentPage = newPage;
|
||||
renderJobsTable(newPage);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.job-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateDeleteButtonState);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-job').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const jobId = e.currentTarget.dataset.id;
|
||||
deleteJob(jobId);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to action buttons
|
||||
document.querySelectorAll('.process-job').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const jobId = e.currentTarget.dataset.id;
|
||||
processJob(jobId);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.view-job').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const jobId = e.currentTarget.dataset.id;
|
||||
viewJobDetails(jobId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('selectAllJobs').addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
document.querySelectorAll('.job-checkbox:not(:disabled)').forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
updateDeleteButtonState();
|
||||
});
|
||||
|
||||
// Function to update delete button state
|
||||
function updateDeleteButtonState() {
|
||||
const deleteBtn = document.getElementById('deleteSelectedJobs');
|
||||
const selectedCheckboxes = document.querySelectorAll('.job-checkbox:checked');
|
||||
deleteBtn.disabled = selectedCheckboxes.length === 0;
|
||||
}
|
||||
|
||||
// Delete selected jobs
|
||||
document.getElementById('deleteSelectedJobs').addEventListener('click', async () => {
|
||||
const selectedIds = Array.from(
|
||||
document.querySelectorAll('.job-checkbox:checked')
|
||||
).map(checkbox => checkbox.value);
|
||||
|
||||
if (!selectedIds.length) return;
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) {
|
||||
await deleteMultipleJobs(selectedIds);
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteJob(jobId) {
|
||||
if (confirm('Are you sure you want to delete this job?')) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids: [jobId] })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
createToast('Job deleted successfully');
|
||||
await loadJobs(currentPage); // Refresh the jobs list
|
||||
} catch (error) {
|
||||
createToast(`Error deleting job: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMultipleJobs(jobIds) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids: jobIds })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
createToast(`${jobIds.length} job(s) deleted successfully`);
|
||||
await loadJobs(currentPage); // Refresh the jobs list
|
||||
} catch (error) {
|
||||
createToast(`Error deleting jobs: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Process job function
|
||||
async function processJob(jobId) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
createToast('Job processing started successfully');
|
||||
await loadJobs(currentPage); // Refresh the jobs list
|
||||
} catch (error) {
|
||||
createToast(`Error processing job: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// View job details function
|
||||
function viewJobDetails(jobId) {
|
||||
// Find the job
|
||||
const job = allJobs.find(j => j.id === jobId);
|
||||
if (!job) return;
|
||||
|
||||
// Prepare modal data
|
||||
document.getElementById('modalJobId').textContent = job.id.substring(0, 8);
|
||||
|
||||
// Format dates
|
||||
const startedDate = new Date(job.created_at);
|
||||
document.getElementById('modalJobStarted').textContent = startedDate.toLocaleString();
|
||||
|
||||
if (job.finished_at) {
|
||||
const completedDate = new Date(job.finished_at);
|
||||
document.getElementById('modalJobCompleted').textContent = completedDate.toLocaleString();
|
||||
} else {
|
||||
document.getElementById('modalJobCompleted').textContent = 'N/A';
|
||||
}
|
||||
|
||||
// Set status with color
|
||||
let status = 'In Progress';
|
||||
let statusClass = 'text-primary';
|
||||
|
||||
if (job.status === 'failed') {
|
||||
status = 'Failed';
|
||||
statusClass = 'text-danger';
|
||||
} else if (job.status === 'completed') {
|
||||
status = 'Completed';
|
||||
statusClass = 'text-success';
|
||||
} else if (job.status === 'pending') {
|
||||
status = 'Pending';
|
||||
statusClass = 'text-warning';
|
||||
} else if (job.status === "processing") {
|
||||
status = 'Processing';
|
||||
statusClass = 'text-info';
|
||||
}
|
||||
|
||||
document.getElementById('modalJobStatus').innerHTML = `<span class="${statusClass}">${status}</span>`;
|
||||
|
||||
// Set other job details
|
||||
document.getElementById('modalJobArrs').textContent = job.arrs.join(', ');
|
||||
document.getElementById('modalJobMediaIds').textContent = job.media_ids && job.media_ids.length > 0 ?
|
||||
job.media_ids.join(', ') : 'All';
|
||||
document.getElementById('modalJobAutoProcess').textContent = job.auto_process ? 'Yes' : 'No';
|
||||
|
||||
// Show/hide error message
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
if (job.error) {
|
||||
document.getElementById('modalJobError').textContent = job.error;
|
||||
errorContainer.classList.remove('d-none');
|
||||
} else {
|
||||
errorContainer.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Process button visibility
|
||||
const processBtn = document.getElementById('processJobBtn');
|
||||
if (job.status === 'pending') {
|
||||
processBtn.classList.remove('d-none');
|
||||
processBtn.onclick = () => {
|
||||
processJob(job.id);
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
|
||||
modal.hide();
|
||||
};
|
||||
} else {
|
||||
processBtn.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Populate broken items table
|
||||
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
|
||||
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
|
||||
brokenItemsTableBody.innerHTML = '';
|
||||
|
||||
let hasBrokenItems = false;
|
||||
|
||||
// Check if broken_items exists and has entries
|
||||
if (job.broken_items && Object.entries(job.broken_items).length > 0) {
|
||||
hasBrokenItems = true;
|
||||
|
||||
// Loop through each Arr's broken items
|
||||
for (const [arrName, items] of Object.entries(job.broken_items)) {
|
||||
if (items && items.length > 0) {
|
||||
// Add each item to the table
|
||||
items.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${arrName}</td>
|
||||
<td><small class="text-muted">${item.path}</small></td>
|
||||
`;
|
||||
brokenItemsTableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide no items message
|
||||
if (hasBrokenItems) {
|
||||
noBrokenItemsMessage.classList.add('d-none');
|
||||
} else {
|
||||
noBrokenItemsMessage.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('jobDetailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Add event listener for refresh button
|
||||
document.getElementById('refreshJobs').addEventListener('click', () => {
|
||||
loadJobs(currentPage);
|
||||
});
|
||||
|
||||
// Load jobs on page load
|
||||
loadJobs(1);
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
32
pkg/web/templates/setup.html
Normal file
32
pkg/web/templates/setup.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{ define "auth" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="authForm" method="POST" action="/auth">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Choose Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Choose Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user